黑科技保活:提升进程优先级的一种思路

4,717 阅读10分钟

本文中的demo地址,文中源码为API 28

一、前言

前不久,看到维术大佬发表的一篇文章:另一种黑科技保活方法。文章内容主要是利用Android的2个bug(黑科技就是利用系统bug骚操作)来提升进程的优先级为前台进程,觉得挺有意思,于是决定找个时间研究一下。因为原文中大佬主要写的是思路,所以流程比较粗略,没有提供具体的demo实现。

可能有些朋友不知道维术大佬,太极·虚拟框架就是他创作的。

我就想着自己简单实现一下,搞个demo看看效果。结果不搞不知道啊,这玩意儿搞起来可太花时间了,太多知识盲区了。

本文将分析Android的这2个bug在哪里、如何才能触发、方案实施、Android修复bug方法、统计各厂商实际效果。

展示一个正常的前台服务

在日常的开发中,展示一个前台服务是经常使用到的一个功能,大致如下:

//创建channel
private void createChannel() {
    NotificationManager manager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
    NotificationChannel channel = new NotificationChannel(CHANNEL_ID, getString(R.string.app_name),
            NotificationManager.IMPORTANCE_HIGH);
    channel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC);
    if (manager != null) {
        manager.createNotificationChannel(channel);
    }
}

//展示通知 成为前台服务
private void showNormalNotify() {
    createChannel();

    Notification notification = new Notification.Builder(this, CHANNEL_ID)
            .setAutoCancel(false)
            .setContentTitle(getString(R.string.app_name))
            .setContentText("运行中...")
            .setWhen(System.currentTimeMillis())
            .setSmallIcon(R.mipmap.ic_launcher_round)
            .setLargeIcon(BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher))
            .build();
    startForeground(1, notification);
}

二、方案1:创建前台服务时传递一个错误channel

前置知识:startForeground流程

首先来分析一下startForeground的流程,方便后续理解。咱们在代码里面使用startForeground()会来到Service#startForeground()中

//Service.java
private IActivityManager mActivityManager = null;
public final void startForeground(int id, Notification notification) {
    try {
        mActivityManager.setServiceForeground(
                new ComponentName(this, mClassName), mToken, id,
                notification, 0);
    } catch (RemoteException ex) {
    }
}

这个方法里面实际上是调用的mActivityManager的setServiceForeground()来完成实际操作。而这个mActivityManager是一个IActivityManager接口,这个接口的实例是谁呢?我通过分析发现在Service#attach中有对其赋值

//Service.java
public final void attach(
        Context context,
        ActivityThread thread, String className, IBinder token,
        Application application, Object activityManager) {
    attachBaseContext(context);
    ......
    mActivityManager = (IActivityManager)activityManager;
}

因之前我写过一篇博客,刚好分析过Service的启动流程,里面见过这个方法。 看到这个熟悉的attach,我知道,肯定是在ActivityThread里面调用的这个方法了。

//ActivityThread.java
private void handleCreateService(CreateServiceData data) {
    //构建Service 利用反射取构建实例
    Service service = null;
    java.lang.ClassLoader cl = packageInfo.getClassLoader();
    service = packageInfo.getAppFactory()
            .instantiateService(cl, data.info.name, data.intent);
    
    //初始化ContextImpl
    ContextImpl context = ContextImpl.createAppContext(this, packageInfo);

    Application app = packageInfo.makeApplication(false, mInstrumentation);
    //注意啦,在这里 传入的是ActivityManager.getService()
    service.attach(context, this, data.info.name, data.token, app,
            ActivityManager.getService());
    //接下来马上就会调用Service的onCreate方法
    service.onCreate();
    
    //mServices是用来存储已经启动的Service的
    mServices.put(data.token, service);
    ....
}

原来传入的是ActivityManager.getService(),就是ActivityManagerService的binder引用。所以,上面的startForeground逻辑来到了ActivityManagerService的setServiceForeground()。

//ActivityManagerService.java
@Override
public void setServiceForeground(ComponentName className, IBinder token,
        int id, Notification notification, int flags) {
    synchronized(this) {
        //mServices中可以找到某个已经启动了的Service
        mServices.setServiceForegroundLocked(className, token, id, notification, flags);
    }
}

//ActiveServices.java
public void setServiceForegroundLocked(ComponentName className, IBinder token,
        int id, Notification notification, int flags) {
    final int userId = UserHandle.getCallingUserId();
    final long origId = Binder.clearCallingIdentity();
    try {
        //根据className, token, userId找到需要创建前台服务的Service的ServiceRecord
        ServiceRecord r = findServiceLocked(className, token, userId);
        if (r != null) {
            setServiceForegroundInnerLocked(r, id, notification, flags);
        }
    } finally {
        Binder.restoreCallingIdentity(origId);
    }
}

/**
* @param id Notification ID.  Zero === exit foreground state for the given service.
*/
private void setServiceForegroundInnerLocked(final ServiceRecord r, int id,
        Notification notification, int flags) {
    if (id != 0) {
        if (notification == null) {
            throw new IllegalArgumentException("null notification");
        }
        // Instant apps 
        if (r.appInfo.isInstantApp()) {
            ......
        } else if (r.appInfo.targetSdkVersion >= Build.VERSION_CODES.P) {
            //Android P以上需要确认有权限
            mAm.enforcePermission(
                    android.Manifest.permission.FOREGROUND_SERVICE,
                    r.app.pid, r.appInfo.uid, "startForeground");
        }
        
        .....
        r.postNotification();
        if (r.app != null) {
            updateServiceForegroundLocked(r.app, true);
        }
        getServiceMapLocked(r.userId).ensureNotStartingBackgroundLocked(r);
        mAm.notifyPackageUse(r.serviceInfo.packageName,
                PackageManager.NOTIFY_PACKAGE_USE_FOREGROUND_SERVICE);
    } else {
        ......
    }
}

ActivityManagerService转手就交给ActiveServices去处理,ActiveServices一顿操作来到ServiceRecord的postNotification,这里就比较重要的,仔细看一下

//ServiceRecord.java
public void postNotification() {
    final int appUid = appInfo.uid;
    final int appPid = app.pid;
    if (foregroundId != 0 && foregroundNoti != null) {
        // Do asynchronous communication with notification manager to
        // avoid deadlocks.
        final String localPackageName = packageName;
        final int localForegroundId = foregroundId;
        final Notification _foregroundNoti = foregroundNoti;
        ams.mHandler.post(new Runnable() {
            public void run() {
                //NotificationManagerService
                NotificationManagerInternal nm = LocalServices.getService(
                        NotificationManagerInternal.class);
                if (nm == null) {
                    return;
                }
                Notification localForegroundNoti = _foregroundNoti;
                try {
                    if (localForegroundNoti.getSmallIcon() == null) {
                        // It is not correct for the caller to not supply a notification
                        // icon, but this used to be able to slip through, so for
                        // those dirty apps we will create a notification clearly
                        // blaming the app.
                        Slog.v(TAG, "Attempted to start a foreground service ("
                                + name
                                + ") with a broken notification (no icon: "
                                + localForegroundNoti
                                + ")");

                        CharSequence appName = appInfo.loadLabel(
                                ams.mContext.getPackageManager());
                        if (appName == null) {
                            appName = appInfo.packageName;
                        }
                        Context ctx = null;
                        try {
                            ctx = ams.mContext.createPackageContextAsUser(
                                    appInfo.packageName, 0, new UserHandle(userId));

                            Notification.Builder notiBuilder = new Notification.Builder(ctx,
                                    localForegroundNoti.getChannelId());

                            // it's ugly, but it clearly identifies the app
                            notiBuilder.setSmallIcon(appInfo.icon);

                            // mark as foreground
                            notiBuilder.setFlag(Notification.FLAG_FOREGROUND_SERVICE, true);

                            Intent runningIntent = new Intent(
                                    Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
                            runningIntent.setData(Uri.fromParts("package",
                                    appInfo.packageName, null));
                            PendingIntent pi = PendingIntent.getActivityAsUser(ams.mContext, 0,
                                    runningIntent, PendingIntent.FLAG_UPDATE_CURRENT, null,
                                    UserHandle.of(userId));
                            notiBuilder.setColor(ams.mContext.getColor(
                                    com.android.internal
                                            .R.color.system_notification_accent_color));
                            notiBuilder.setContentTitle(
                                    ams.mContext.getString(
                                            com.android.internal.R.string
                                                    .app_running_notification_title,
                                            appName));
                            notiBuilder.setContentText(
                                    ams.mContext.getString(
                                            com.android.internal.R.string
                                                    .app_running_notification_text,
                                            appName));
                            notiBuilder.setContentIntent(pi);

                            localForegroundNoti = notiBuilder.build();
                        } catch (PackageManager.NameNotFoundException e) {
                        }
                    }

                    //注意了,如果是没有创建channel,则会抛出一个RuntimeException
                    if (nm.getNotificationChannel(localPackageName, appUid,
                            localForegroundNoti.getChannelId()) == null) {
                        int targetSdkVersion = Build.VERSION_CODES.O_MR1;
                        try {
                            final ApplicationInfo applicationInfo =
                                    ams.mContext.getPackageManager().getApplicationInfoAsUser(
                                            appInfo.packageName, 0, userId);
                            targetSdkVersion = applicationInfo.targetSdkVersion;
                        } catch (PackageManager.NameNotFoundException e) {
                        }
                        if (targetSdkVersion >= Build.VERSION_CODES.O_MR1) {
                            throw new RuntimeException(
                                    "invalid channel for service notification: "
                                            + foregroundNoti);
                        }
                    }
                    if (localForegroundNoti.getSmallIcon() == null) {
                        // Notifications whose icon is 0 are defined to not show
                        // a notification, silently ignoring it.  We don't want to
                        // just ignore it, we want to prevent the service from
                        // being foreground.
                        throw new RuntimeException("invalid service notification: "
                                + foregroundNoti);
                    }
                    nm.enqueueNotification(localPackageName, localPackageName,
                            appUid, appPid, null, localForegroundId, localForegroundNoti,
                            userId);

                    foregroundNoti = localForegroundNoti; // save it for amending next time
                } catch (RuntimeException e) {
                    //上面的Exception 在这里会被捕获  展示Notification失败了
                    Slog.w(TAG, "Error showing notification for service", e);
                    // If it gave us a garbage notification, it doesn't
                    // get to be foreground.
                    //给我一个垃圾Notification,还想成为前台服务?妄想
                    ams.setServiceForeground(name, ServiceRecord.this,
                            0, null, 0);
                    //调用AMS#crashApplication()
                    ams.crashApplication(appUid, appPid, localPackageName, -1,
                            "Bad notification for startForeground: " + e);
                }
            }
        });
    }
}

这段代码的核心思想是构建Notification,然后告知NotificationManagerService需要展示通知。在展示通知之前,会先判断一下是否有为这个通知创建好channel,如果没有则抛出异常,然后方法末尾的catch会将抛出的异常给捕获住。

捕获住异常之后,系统执行收尾清理工作。系统知道这个通知创建失败了,将该Service设置为非前台。然后调用AMS的crashApplication(),看着方法名看起来是想营造一个crash给app。咱跟下去,看看是啥情况

@Override
public void crashApplication(int uid, int initialPid, String packageName, int userId,
        String message) {
    synchronized(this) {
        //mAppErrors是AppErrors
        mAppErrors.scheduleAppCrashLocked(uid, initialPid, packageName, userId, message);
    }
}

//AppErrors.java
/**
* Induce a crash in the given app.
*/
void scheduleAppCrashLocked(int uid, int initialPid, String packageName, int userId,
        String message) {
    ProcessRecord proc = null;

    // Figure out which process to kill.  We don't trust that initialPid
    // still has any relation to current pids, so must scan through the
    // list.

    synchronized (mService.mPidsSelfLocked) {
        for (int i=0; i<mService.mPidsSelfLocked.size(); i++) {
            ProcessRecord p = mService.mPidsSelfLocked.valueAt(i);
            if (uid >= 0 && p.uid != uid) {
                continue;
            }
            if (p.pid == initialPid) {
                proc = p;
                break;
            }
            if (p.pkgList.containsKey(packageName)
                    && (userId < 0 || p.userId == userId)) {
                proc = p;
            }
        }
    }
    proc.scheduleCrash(message);
}

好家伙,从AppErrors的scheduleAppCrashLocked()注释看,是让一个app崩溃。

//ProcessRecord.java
IApplicationThread thread; 
void scheduleCrash(String message) {
    // Checking killedbyAm should keep it from showing the crash dialog if the process
    // was already dead for a good / normal reason.
    if (!killedByAm) {
        if (thread != null) {
            long ident = Binder.clearCallingIdentity();
            try {
                //thread是IApplicationThread,实际上是ActivityThread中的ApplicationThread
                thread.scheduleCrash(message);
            } catch (RemoteException e) {
                // If it's already dead our work is done. If it's wedged just kill it.
                // We won't get the crash dialog or the error reporting.
                kill("scheduleCrash for '" + message + "' failed", true);
            } finally {
                Binder.restoreCallingIdentity(ident);
            }
        }
    }
}

ProcessRecord的scheduleCrash()的核心代码是执行thread的scheduleCrash()。但是这个thread是什么,我们暂时不知道。

这里的thread是IApplicationThread,IApplicationThread是一个接口并且继承自android.os.IInterface,它在源码中的存在形式是IApplicationThread.aidl (路径:frameworks/base/core/java/android/app/IApplicationThread.aidl),在线源码观看地址。 看起来是在跨进程通信,通信双方是AMS进程与app进程。app端接收消息的地方在ActivityThread的ApplicationThread

public final class ActivityThread extends ClientTransactionHandler {
    private class ApplicationThread extends IApplicationThread.Stub {
        //ApplicationThread是ActivityThread的内部类
        //看这个标准的样子,就知道肯定和aidl有关
    }
}

于是上面的ProcessRecord的scheduleCrash()其实是想通知ApplicationThread执行scheduleCrash(),注意,这里是跨进程的。

//ActivityThread#ApplicationThread
public void scheduleCrash(String msg) {
    sendMessage(H.SCHEDULE_CRASH, msg);
}

void sendMessage(int what, Object obj) {
    sendMessage(what, obj, 0, 0, false);
}

private void sendMessage(int what, Object obj, int arg1, int arg2, boolean async) {
    Message msg = Message.obtain();
    msg.what = what;
    msg.obj = obj;
    msg.arg1 = arg1;
    msg.arg2 = arg2;
    if (async) {
        msg.setAsynchronous(true);
    }
    mH.sendMessage(msg);
}

而在ApplicationThread的scheduleCrash()方法中,看起来只是发了个消息给mH这个Handler。

//ActivityThread.java
final H mH = new H();

class H extends Handler {
    public static final int SCHEDULE_CRASH          = 134;

    public void handleMessage(Message msg) {
        if (DEBUG_MESSAGES) Slog.v(TAG, ">>> handling: " + codeToString(msg.what));
        switch (msg.what) {
            ......
            case SCHEDULE_CRASH:
                throw new RemoteServiceException((String)msg.obj);
        }
    }
}

H这个Handler我们再熟悉不过了,什么绑定Application、绑定Service、停止Service、Activity生命周期回调什么的,都得靠这个Handler。H这个Handler在接收到SCHEDULE_CRASH这个消息时,会抛出一个RemoteServiceException。

到这里,startForeground()时传入一个不存在的channel的流程就走完了,系统会抛出一个异常导致app崩溃。正常情况下,这是没有什么问题的。

这里的漏洞是什么?

假设我把这个消息拦截下来,然后不抛出错误,那app岂不是正常继续运行咯。确实是这样。

方案实施

思路1 拦截消息

系统让app这边抛出一个异常,自行结束生命。那我收到系统给的指示,然后不抛出异常,不就可以绕过了么?那么,怎么绕?

可以hook这个ActivityThread的H,拦截其SCHEDULE_CRASH消息,然后做自己想做的事情。

大体思路倒是有了,具体如何实现呢? 要hook这个H,那首先我们要拿到ActivityThread的实例(一个app进程对应着一个ActivityThread)。在搜寻ActivityThread的API过程中发现一个东西

/** Reference to singleton {@link ActivityThread} */
private static volatile ActivityThread sCurrentActivityThread;

public static ActivityThread currentActivityThread() {
    return sCurrentActivityThread;
}

private void attach(boolean system, long startSeq) {
    sCurrentActivityThread = this;
    ......
}

public static void main(String[] args) {
    Looper.prepareMainLooper();

    ActivityThread thread = new ActivityThread();
    thread.attach(false, startSeq);

    if (sMainThreadHandler == null) {
        sMainThreadHandler = thread.getHandler();
    }

    Looper.loop();

    throw new RuntimeException("Main thread loop unexpectedly exited");
}

我注意到有一个sCurrentActivityThread的东西,在main方法里面一开始就初始化好了,然后从它的注释也能看出它是一个全局单例,即它就是ActivityThread的实例了,拿到它就好办了。然后接着我发现一个currentActivityThread()的静态方法,妙啊,原来系统早就想好了,给我们提供了一个public静态方法方便获取ActivityThread实例。我兴致冲冲地跑去Activity里面使用时,却发现,我好像连ActivityThread这个类都无法访问。

/**
 * {@hide}
 */
public final class ActivityThread extends ClientTransactionHandler {}

好家伙,加了{@hide},静态方法是用不起了。虽然静态方法是用不起了,但是我们可以反射拿到这个sCurrentActivityThread静态变量。

//拿ActivityThread的class对象
Class<?> activityThreadClazz = Class.forName("android.app.ActivityThread");
Field sCurrentActivityThread = activityThreadClazz.getDeclaredField("sCurrentActivityThread");
sCurrentActivityThread.setAccessible(true);
Object activityThread = sCurrentActivityThread.get(activityThreadClazz);

ActivityThread实例倒是拿到了,接下来我们需要拦截里面的H这个Handler的消息。自己写一个Handler然后把原来的H这个Handler替换掉?不行,里面那么多逻辑,我们自己搞风险太大了,而且不现实。但是,我们可以给这个Handler设置一个mCallback。回忆一下:

//Handler.java
/**
 * Handle system messages here.
 */
public void dispatchMessage(Message msg) {
    if (msg.callback != null) {
        handleCallback(msg);
    } else {
        if (mCallback != null) {
            if (mCallback.handleMessage(msg)) {
                return;
            }
        }
        handleMessage(msg);
    }
}

Handler在分发消息时,发现mCallback不为空,则先交给mCallback处理,如果mCallback处理结果返回false,再交给handleMessage进行处理。

基于这个,咱思路有了,hook那个Handler的mCallback,然后只处理SCHEDULE_CRASH这个消息,其他的不管,还是交给原来的Handler的handleMessage进行处理。因为我们只处理SCHEDULE_CRASH这个消息,所以把风险降到了最低。

思路有了,show me the code:

//拿到SCHEDULE_CRASH的int值,源码里面写的是134,为了防止官方后面修改了这个值,这个134不直接写死
Class<?> HClass = Class.forName("android.app.ActivityThread$H");
Field scheduleCrashField = HClass.getDeclaredField("SCHEDULE_CRASH");
scheduleCrashField.setAccessible(true);
final int whatForScheduleCrash = scheduleCrashField.getInt(HClass);

//拿mH实例
Field mHField = activityThreadClazz.getDeclaredField("mH");
mHField.setAccessible(true);
Handler mH = (Handler) mHField.get(activityThread); 

//给mH设置一个mCallback
Class<?> handlerClass = Class.forName("android.os.Handler");
Field mCallbackField = handlerClass.getDeclaredField("mCallback");
mCallbackField.setAccessible(true);
mCallbackField.set(mH, new Handler.Callback() {
    @Override
    public boolean handleMessage(@NonNull Message msg) {
        if (msg.what == whatForScheduleCrash) {
            Log.d("xfhy_hook", "收到一杯罚酒,我干了,你随意");
            return true;
        }
        return false;
    }
});

好了,到这里,我们已经hook成功了。现在去启动前台服务,用一个没有创建channel的通知看起来也不会崩溃了(也不一定,厂商可能修改了这部分逻辑,后面有验证结果)。这种办法启动的前台服务是不会展示任何通知在状态栏上的,用户无感知。

思路2 Handle the exception in main loop

大家先看看下面这段代码,就这么一小段代码即可达到与思路1同样的效果。

new Handler(Looper.getMainLooper()).post(new Runnable() {
    @Override
    public void run() {
        while (true) {
            try {
                Looper.loop();
            } catch (Throwable e) {
                e.printStackTrace();
            }
        }
    }
});

给主线程的Looper发送了一个消息,这个消息的callback是上面的这个Runnable,实际执行逻辑是一段看起来像死循环一样的代码。

分析一下,我们知道,在主线程中维护了Handler的消息机制,在应用启动的时候就做好了Looper的创建和初始化,然后开始使用Looper.loop()循环处理消息。

//ActivityThread.java
public static void main(String[] args) {
    //准备主线的MainLooper
    Looper.prepareMainLooper();

    ActivityThread thread = new ActivityThread();
    thread.attach(false, startSeq);

    if (sMainThreadHandler == null) {
        sMainThreadHandler = thread.getHandler();
    }

    //开始loop循环
    Looper.loop();

    //loop循环是不能结束的,否则app就会异常退出咯
    throw new RuntimeException("Main thread loop unexpectedly exited");
}

我们在使用app的过程中,用户的所有操作事件、Activity生命周期回调、列表滑动等等,都是通过Looper的loop循环中完成处理的,其本质是将消息加入MessageQueue队列,然后循环从这个队列中取出消息并处理。如果没有消息可以处理的时候,会依靠Linux的epoll机制暂时挂起等待唤醒。下面是loop的核心代码:

public static void loop() {
    final Looper me = myLooper();
    final MessageQueue queue = me.mQueue;
    for (;;) {
        Message msg = queue.next(); 
        msg.target.dispatchMessage(msg);
    }
}

死循环,不断取消息,没有消息的话就暂时挂起。我们上面那段短小精炼的代码,通过Handler往主线程发送了一个Runnable任务,然后在里面执行了一个死循环,死循环地执行Looper的loop方法读取消息。只要Looper的loop方法执行到了咱这个Message的callback,那么后面所有的主线程消息都会走到我们这个loop方法中进行处理。一旦发生了主线程崩溃,那么这里就可以进行异常捕获。然后又是死循环,捕获到异常之后,又开始继续执行Looper的loop方法,这样主线程就可以一直正常读取消息,刷新UI啥的都是正常的,不会有影响。

这样的话,不管在ActivityThread#mH的handleMessage()中抛出什么异常都没事了。

Android是如何修复的

还好,谷歌在2020.8就修复了这个问题。

//ServiceRecord.java
@@ -798,6 +798,7 @@
             final String localPackageName = packageName;
             final int localForegroundId = foregroundId;
             final Notification _foregroundNoti = foregroundNoti;
+            final ServiceRecord record = this;
             ams.mHandler.post(new Runnable() {
                 public void run() {
                     NotificationManagerInternal nm = LocalServices.getService(
@@ -896,10 +897,8 @@
                         Slog.w(TAG, "Error showing notification for service", e);
                         // If it gave us a garbage notification, it doesn't
                         // get to be foreground.
-                        ams.setServiceForeground(instanceName, ServiceRecord.this,
-                                0, null, 0, 0);
-                        ams.crashApplication(appUid, appPid, localPackageName, -1,
-                                "Bad notification for startForeground: " + e);
+                        ams.mServices.killMisbehavingService(record,
+                                appUid, appPid, localPackageName);
                     }
                 }
             });

//ActiveServices.java
+    void killMisbehavingService(ServiceRecord r,
+            int appUid, int appPid, String localPackageName) {
+        synchronized (mAm) {
+            stopServiceLocked(r);
+            mAm.crashApplication(appUid, appPid, localPackageName, -1,
+                    "Bad notification for startForeground", true /*force*/);
+        }
+    }
+

//AppErrors.java
//如果force是true,则5秒之后把app干死
if (force) {
    // If the app is responsive, the scheduled crash will happen as expected
    // and then the delayed summary kill will be a no-op.
    final ProcessRecord p = proc;
    mService.mHandler.postDelayed(
            () -> killAppImmediateLocked(p, "forced", "killed for invalid state"),
            5000L);
}

postNotification()的时候,如果发现是前台服务,那么将调用停掉该前台服务,当然crashApplication()还是得调的。

三、方案2:创建前台服务时搞一个错误布局

系统如何处理创建前台服务时的错误布局

这里就不带大家分析了,创建前台服务时遇到错误布局,最后会来到onNotificationError()。

@Override
public void onNotificationError(int callingUid, int callingPid, String pkg, String tag, int id,
        int uid, int initialPid, String message, int userId) {
    cancelNotification(callingUid, callingPid, pkg, tag, id, 0, 0, false, userId,
            REASON_ERROR, null);
}

可以看到,连崩溃都没有,只是简单取消一下通知就完了。

这里的漏洞是什么?

既然没有崩溃,那开发者就可以传递一个错误的布局id过来,然后只是通知被取消了,前台服务还是被创建成功了,而且还是没有展示通知的。

方案实施

只需要在开启前台服务的时候,自定义布局那里传递一个不存在的布局id即可。

private void showErrorLayoutNotify() {
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
        return;
    }

    createChannel();

    RemoteViews remoteViewTemplate = new RemoteViews(getPackageName(), /*R.layout.layout_test*/4);

    NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID);
    builder.setOngoing(true);
    builder.setContent(remoteViewTemplate);
    builder.setTicker("fuck");
    builder.setPriority(NotificationCompat.PRIORITY_LOW);
    builder.setSmallIcon(R.mipmap.ic_launcher_round);
    try {
        startForeground(1, builder.build());
    } catch (Exception e) {
        e.printStackTrace();
    }
}

Android是如何修复的

同样也是在2020.8修复了该问题,crashApplication方法这次传递的force是true,必须死。

//NotificationManagerService.java
@Override
public void onNotificationError(int callingUid, int callingPid, String pkg, String tag,
        int id, int uid, int initialPid, String message, int userId) {
    final boolean fgService;
    synchronized (mNotificationLock) {
        NotificationRecord r = findNotificationLocked(pkg, tag, id, userId);
        fgService = r != null && (r.getNotification().flags & FLAG_FOREGROUND_SERVICE) != 0;
    }
    cancelNotification(callingUid, callingPid, pkg, tag, id, 0, 0, false, userId,
            REASON_ERROR, null);
    if (fgService) {
        // Still crash for foreground services, preventing the not-crash behaviour abused
        // by apps to give us a garbage notification and silently start a fg service.
        Binder.withCleanCallingIdentity(
                () -> mAm.crashApplication(uid, initialPid, pkg, -1,
                    "Bad notification(tag=" + tag + ", id=" + id + ") posted from package "
                        + pkg + ", crashing app(uid=" + uid + ", pid=" + initialPid + "): "
                        + message, true /* force */));
    }
}

四、实际效果

测试方式:测试时App在前台弹出Service,然后按home键回到桌面,此时看app的adj(进程优先级,0表示在前台,越小则表示优先级越高)状态。

先使用命令行adb shell ps -A | grep xfhy找到我的demo进程,查看进程号

u0_a85       10502  1796 1445700 104284 0                   0 S com.xfhy.frontservicedemo

上面的10502即是进程号,然后通过adb shell cat proc/10502/oom_adj查看该进程adj值。这种方式只适用于部分手机,一些手机上会提示你没有权限,这时只能使用adb shell dumpsys activity processes,然后找到你的进程。如下面的日志中,有一个oom: max=1001 curRaw=0 setRaw=0 cur=0 set=0的值,勉强可以看出点东西,这些值也是越小则优先级越高。

*APP* UID 10085 ProcessRecord{587081b 10502:com.xfhy.frontservicedemo/u0a85}
    user #0 uid=10085 gids={50085, 20085, 9997}
    requiredAbi=x86 instructionSet=null
    dir=/data/app/com.xfhy.frontservicedemo-rujs7qyX7dkx9BoO0UwMug==/base.apk publicDir=/data/app/com.xfhy.frontservicedemo-rujs7qyX7dkx9BoO0UwMug==/base.apk data=/data/user/0/com.xfhy.frontservicedemo
    packageList={com.xfhy.frontservicedemo}
    compat={320dpi}
    thread=android.app.IApplicationThread$Stub$Proxy@ed8ddb8
    pid=10502 starting=false
    lastActivityTime=-2m14s174ms lastPssTime=-36s962ms pssStatType=0 nextPssTime=+53s9ms
    adjSeq=17211 lruSeq=0 lastPss=32MB lastSwapPss=0.00 lastCachedPss=0.00 lastCachedSwapPss=0.00
    procStateMemTracker: best=1 (1=1 2.25x)
    cached=false empty=false
    oom: max=1001 curRaw=0 setRaw=0 cur=0 set=0
    curSchedGroup=3 setSchedGroup=3 systemNoUi=false trimMemoryLevel=0
    curProcState=2 repProcState=2 pssProcState=2 setProcState=2 lastStateTime=-2m14s174ms
    hasShownUi=true pendingUiClean=true hasAboveClient=false treatLikeActivity=false
    reportedInteraction=true time=-2m14s178ms
    hasClientActivities=false foregroundActivities=true (rep=true)
    startSeq=86
    lastRequestedGc=-2m14s211ms lastLowMemory=-2m14s211ms reportLowMemory=false
    Activities:
      - ActivityRecord{ee9b8e4 u0 com.xfhy.frontservicedemo/.MainActivity t17}
    Recent Tasks:
      - TaskRecord{a34a791 #17 A=com.xfhy.frontservicedemo U=0 StackId=12 sz=1}
    Connected Providers:
      - 6e7baff/com.android.providers.settings/.SettingsProvider->10502:com.xfhy.frontservicedemo/u0a85 s1/1 u0/0 +2m12s890ms
  • 方案A : 正常展示通知的方式启动Service,作为对比
  • 方案B : 展示通知时,使用一个没有注册的channel
  • 方案C : 展示通知时,使用一个错误的布局
手机Android版本ROM 版本补丁版本方案A方案B方案C
荣耀6x8.08.02020.9oom cur=200oom cur=200oom cur=200
小米810122020.9oom cur=505秒后崩溃5秒后崩溃
小米6810oom cur=200oom cur=200oom cur=200
vivo nex109.2adj=0adj=7adj=0
三星 Galaxy A60102020.12崩溃崩溃
原生92019.8adj=3adj=11adj=3

从实际效果来看,大部分情况下表现良好,但是部分手机上可能导致app崩溃,这是不能接受的。比较有趣的是,部分手机打了补丁依然能正常运行,没有杀死app。

五、题外话

  • 系统有提示升级就尽快升级,里面可能修复了大量漏洞之类的,让手机更安全,买手机尽量选择更新系统比较频繁的。
  • 2021年了,保活基本上是不太可能了,各大厂商招揽顶尖人才搞出稳定的Android系统,和系统抗衡是不可能的。还是好好做好产品,让用户爱上产品才是真正的保活
  • 严正声明:本文相关技术仅限于技术研究使用,不能用于非法目的,否则后果自负

六、资料