【杰哥带你玩转Android自动化】AccessibilityService拾遗-保活与防御

3,918 阅读7分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

0x1、引言

Hi,我是杰哥,在上一节《AccessibilityService实战-微信僵尸好友检测》中带大家利用所学的AccessibilityService基础知识,借鉴真实好友假转账的原理,实现了自己的专属微信僵尸好友检测工具。相信认真学完的读者对于自定义无障碍服务的开发流程都了然于胸,以后随手写个自动化小工具估摸着也是手到擒来了~

本节主要是拾遗,补充两点锦上添花的小细节:AccessibilityService实战的保活与防御。不哔哔,直接开始~


0x2、无障碍服务保活

应用保活,老生常谈的话题了,最早可以追溯到7年前的一个库 MarsDaemon双进程守护,简单配置几行代码,即可实现进程常驻。

不过好景不长,Android 8.0后这个库就废掉了,后面陆续涌现了很多保活的 骚操作,如 1个像素的Activity播放无声音频 等。

// 例:1像素的Activity
class OnePxActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        window.attributes = window.apply { setGravity(Gravity.START or Gravity.TOP) }
            .attributes.apply {
                width = 1
                height = 1
                x = 1
                y = 1
            }
    }
}

当然这些骚操作,并不太通用靠谱,毕竟哪个厂商的底层不魔改一下,每家都有自己的一套管理系统。说句大实话:终极保活的技巧就是钞能力——花钱进厂商白名单

没有钞能力也没关系,有一些通用可行的小技巧,可以提高你的APP的优先级,降低进程被杀的概率~


① 前台服务

把原本处于后台运行AccessibilityService设置为前台服务,需要在AndroidManifest.xml清单文件中声明下述权限:

<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>

否则会报异常:

java.lang.SecurityException: Permission Denial:
 startForeground from pid=2345, uid=10395 requires android.permission.FOREGROUND_SERVICE.

接着在 onCreate() 方法中创建Notification渠道,并开启前台服务,在 onDestory() 方法中停止前台服务,直接给出工具代码,读者按需修改即可:

class ClearCorpseAccessibilityService : AccessibilityService() {
        ...
        override fun onCreate() {
        super.onCreate()
        // 创建Notification渠道,并开启前台服务
        createForegroundNotification()?.let { startForeground(1, it) }
    }

    override fun onDestroy() {
        // 停止前台服务
        stopForeground(true)
        super.onDestroy()
    }

    private fun createForegroundNotification(): Notification? {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val notificationManager =
                getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
            // 创建通知渠道,一定要写在创建显示通知之前,创建通知渠道的代码只有在第一次执行才会创建
            // 以后每次执行创建代码检测到该渠道已存在,因此不会重复创建
            val channelId = "前台通知id名,任意"
            notificationManager?.createNotificationChannel(
                NotificationChannel(
                    channelId,
                    "前台通知名称,任意",
                    NotificationManager.IMPORTANCE_HIGH // 发送通知的等级,此处为高
                ).apply {
                    // 下述都是非必要的,看自己需求配置
                    enableLights(true)  // 如果设备有指示灯,开启指示灯
                    lightColor = Color.GREEN    // 设置指示灯颜色
                    enableVibration(true)   // 开启震动
                    vibrationPattern = longArrayOf(100, 200, 300, 400)  // 设置震动频率
                    setShowBadge(true)  // 是否显示角标
                    setBypassDnd(true)  // 是否绕过免打扰模式
                    lockscreenVisibility = Notification.VISIBILITY_PRIVATE  // 是否在锁屏屏幕上显示此频道的通知
                }
            )
            return NotificationCompat.Builder(this, channelId)
                // 设置点击notification跳转,比如跳转到设置页
                .setContentIntent(
                    PendingIntent.getActivity(
                        this,
                        0,
                        Intent(this, SettingActivity::class.java),
                        FLAG_IMMUTABLE
                    )
                )
                .setSmallIcon(R.drawable.ic_service_enable) // 设置小图标
                .setContentTitle("通知标题")
                .setContentText("通知内容")
                .setTicker("通知提示语")
                .build()
        }
        return null
    }
    ...
}

运行后,在顶部通知栏可以看到前台服务的Notification:


② 取消电池优化限制

Android 6.0后为了省电,添加了休眠模式,系统待机一段时间后会杀死后台正常运行的进程,但系统会有一个 后台运行白名单

早期的原生系统中,依次点击:设置 → 电池 → 电池优化 → 未优化应用,可以看到这个白名单。

而在后续的系统中(如我的Android 10),得去 应用和通知 找:找到自己的应用点击电池后台限制

接着是:判断APP是否受电池优化限制申请取消电池优化限制 的工具代码:

// 需在AndroidManifest.xml中添加下述权限
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>

// 判断APP是否被限制
@RequiresApi(api = Build.VERSION_CODES.M)
private fun isIgnoringBatteryOptimizations() =
    (getSystemService(Context.POWER_SERVICE) as PowerManager)
        .isIgnoringBatteryOptimizations(packageName)

// 申请取消限制
@RequiresApi(api = Build.VERSION_CODES.M)
fun requestIgnoreBatteryOptimizations() {
    try {
        startActivity(Intent(ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
            data = Uri.parse("package:$packageName")
        })
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

申请时会有这样的弹窗 (不同手机系统各有差异):


③ 引导用户开启自启动

先是判断应用 是否开启自启动权限,很遗憾,笔者并没有找到 通用且公开的API,只找到有人通过 反射方式获得的,测试了一下,并不靠谱。

所以一个折中的方案:存一个自启动引导页是否打开的标记,弹之前判断下弹过没,没弹过就弹,弹过就不弹,如果弹了就修改下标记。

接着是跳转到设置页,因为厂商对系统的不同定制,导致 开启自启动 (有些都不叫这个名字) 的设置入口就五花八门,所以需要开发者根据不同品牌机型自行适配。

先判断是哪家的手机,然后跳转对应的设置页,笔者根据网上的几篇文章,简单地整理了一下 (因笔者测试机有限,未能全部测试覆盖,不对的欢迎评论区提出):

object RomUtil {
    // 系统名
    const val ROM_MIUI = "MIUI" // 小米
    const val ROM_EMUI = "EMUI" // 华为
    const val ROM_OPPO = "OPPO" // OPPO
    const val ROM_VIVO = "VIVO" // VIVO
    const val ROM_SMARTISAN = "SMARTISAN"   // 锤子
    const val ROM_FLYME = "FLYME"   // 魅族
    const val ROM_QIKU = "QIKU" // 360

    // 对应系统有的属性
    private const val KEY_VERSION_MIUI = "ro.miui.ui.version.name"
    private const val KEY_VERSION_EMUI = "ro.build.version.emui"
    private const val KEY_VERSION_OPPO = "ro.build.version.opporom"
    private const val KEY_VERSION_SMARTISAN = "ro.smartisan.version"
    private const val KEY_VERSION_VIVO = "ro.vivo.os.version"

    // getprop命令去系统build.prop查找是否有对应属性来判断
    private fun getProp(name: String): String? {
        val line: String?
        var input: BufferedReader? = null
        try {
            val process = Runtime.getRuntime().exec("getprop $name")
            input = BufferedReader(InputStreamReader(process.inputStream), 1024)
            line = input.readLine()
            input.close()
        } catch (ex: IOException) {
            Log.e(TAG, "Unable to read prop $name", ex)
            return null
        } finally {
            if (input != null) {
                try {
                    input.close()
                } catch (e: IOException) {
                    e.printStackTrace()
                }
            }
        }
        return line
    }

    // 判断系统的方法
    private fun check(rom: String): Boolean {
        val tempRom: String?
        if (!getProp(KEY_VERSION_MIUI).isNullOrBlank()) {
            tempRom = ROM_MIUI
        } else if (!getProp(KEY_VERSION_EMUI).isNullOrBlank()) {
            tempRom = ROM_EMUI
        } else if (!getProp(KEY_VERSION_OPPO).isNullOrBlank()) {
            tempRom = ROM_OPPO
        } else if (!getProp(KEY_VERSION_VIVO).isNullOrBlank()) {
            tempRom = ROM_VIVO
        } else if (!getProp(KEY_VERSION_SMARTISAN).isNullOrBlank()) {
            tempRom = ROM_SMARTISAN
        } else {
            val version = Build.DISPLAY
            tempRom = if (version.uppercase().contains(ROM_FLYME)) {
                ROM_FLYME
            } else {
                Build.MANUFACTURER.uppercase()
            }
        }
        return rom == tempRom
    }

    fun isXiaomi() = check(ROM_MIUI)

    fun isHuawei() = check(ROM_EMUI)

    fun isVivo() = check(ROM_VIVO)

    fun isOppo() = check(ROM_OPPO)

    fun isFlyme() = check(ROM_FLYME)

    fun is360() = check(ROM_QIKU) || check("360")

    fun isSmartisan() = check(ROM_SMARTISAN)

    // 打开自启动设置页
    fun openStart(context: Context) {
        if (Build.VERSION.SDK_INT < 23) return
        var intent = Intent()
        var componentName: ComponentName? = null
        when {
            isXiaomi() -> {
                componentName = ComponentName(
                    "com.miui.securitycenter",
                    "com.miui.permcenter.autostart.AutoStartManagementActivity"
                )
            }
            isHuawei() -> {
                componentName = ComponentName(
                    "com.huawei.systemmanager",
                    "com.huawei.systemmanager.startupmgr.ui.StartupNormalAppListActivity"
                )
            }
            isOppo() -> {
                componentName = if (Build.VERSION.SDK_INT >= 26) {
                    ComponentName(
                        "com.coloros.safecenter",
                        "com.coloros.safecenter.startupapp.StartupAppListActivity"
                    )
                } else {
                    ComponentName(
                        "com.color.safecenter",
                        "com.color.safecenter.permission.startup.StartupAppListActivity"
                    )
                }
            }
            isVivo() -> {
                componentName = if (Build.VERSION.SDK_INT >= 26) {
                    ComponentName(
                        "com.vivo.permissionmanager",
                        "com.vivo.permissionmanager.activity.PurviewTabActivity"
                    )
                } else {
                    ComponentName(
                        "com.iqoo.secure",
                        "com.iqoo.secure.ui.phoneoptimize.SoftwareManagerActivity"
                    )
                }
            }
            isFlyme() -> {
                componentName = ComponentName.unflattenFromString(
                    "com.meizu.safe/.permission.PermissionMainActivity"
                )
            }
            else -> {
                if (Build.VERSION.SDK_INT >= 9) {
                    intent.action = "android.settings.APPLICATION_DETAILS_SETTINGS";
                    intent.data = Uri.fromParts("package", context.packageName, null);
                } else if (Build.VERSION.SDK_INT <= 8) {
                    intent.action = Intent.ACTION_VIEW
                    intent.setClassName(
                        "com.android.settings",
                        "com.android.settings.InstalledAppDetails"
                    );
                    intent.putExtra(
                        "com.android.settings.ApplicationPkgName",
                        context.packageName
                    )
                }
                intent = Intent(Settings.ACTION_SETTINGS)
            }
        }
        componentName?.let { intent.setComponent(it) }
        try {
            context.startActivity(intent)
        } catch (e: Exception) {
            // 抛出异常的话直接打开设置页
            context.startActivity(Intent(Settings.ACTION_SETTINGS))
        }
    }
}

④ 引导用户在多任务列表窗口加锁

如题,引导用户对 多任务列表的APP窗口加锁,这样点击将清理加速时不会导致应用被杀,如:

另外,还有一个骚操作:在多任务列表把App窗口给隐藏了,避免用户手多划掉,工具代码如下:

fun Context.hideAppWindow(isHide: Boolean) {
    try {
        (getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager)
            .appTasks[0].setExcludeFromRecents(isHide)
    } catch (e: Exception) {
        //...
    }
}

⑤ 引导用户打开APP后台高耗电开关

部分厂商的手机有这个(如Vivo),设置方式:设置 → 电池 → 后台高耗电 → 找到自己的APP开启


0x3、无障碍服务防御

在一开始学习AccessibilityService的时候就提到过,这个服务设计的初衷是:为了帮助残障人士可以更好的使用App

而在国内一些开发者利用它 能监控与操作其它APP的特性 + 系统远超人类的反应速度,在某些竞争类场景开发出了 作弊外挂,如抢单、秒杀等,对原本公平的竞争环境产生不公。

作为一名普通的Android开发者,还是要居安思危,指不定哪天自己开发的APP也会惨招毒手,提前了解一些AccessibilityService的防御措施,不至于真发生时不知所措~


① 检测用户是否安装外挂软件

建立外挂软件黑名单PackageManager遍历手机已安装的APP,判断是有有黑名单里的包名和应用名,有给个提示,然后退出APP。

但,这需要权限,而且涉及到了隐私,所以,可以尝试换个思路 → 检测监控包名的AccessibilityService

可以通过 AccessibilityManagerService 获取所有已安装及已启动的AccessibilityService应用,而它是com.android.server.accessibility包下的类,无法直接使用。但可以通过 AccessibilityManager 来间接操作(Binder)。提供了两个获取 List<AccessibilityServiceInfo> 的方法:

  • getInstalledAccessibilityServiceList() → 获得所有已安装的AccessibilityService;
  • getEnabledAccessibilityServiceList() → 获得所有已启动的AccessibilityService;

以第一个获取方法为例,写出遍历的工具代码:

// 取得正在监控目标包名的AccessibilityService
fun getInstalledAccessibilityServiceList(targetPackage: String): List<AccessibilityServiceInfo> {
    val serviceList = arrayListOf<AccessibilityServiceInfo>()
    val manager =
        applicationContext.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager
            ?: return serviceList
    val infoList = manager.installedAccessibilityServiceList
    if (infoList.isNullOrEmpty()) return serviceList
    infoList.forEach {
        if (it.packageNames == null) serviceList.add(it) else {
            it.packageNames.forEach { pkgName ->
                if (targetPackage == pkgName) serviceList.add(
                    it
                )
            }
        }
    }
    return serviceList
}

简单调用下 (检测监听微信的无障碍服务有哪些):

getInstalledAccessibilityServiceList("com.tencent.mm").forEach { info ->
    logD(
        " \n【监听的包名 (null代表所有)】${info.packageNames?.toList()}\n【监听的服务】${info.id}\n【设置页】${info.settingsActivityName}\n【服务描述信息】${
            info.description.replace("\n", "").replace(" ", "")
        }"
    )
}

运行后控制台输出信息如下 (注:info.packageNames为null表示监控所有包名):

可以看到手机安装的所有监听微信的无障碍服务App信息都被打印出来了,接着就是检查这里面有没有外挂黑名单里的包名了。

至于检测时机,可以定时或者在特定时间节点进行,尽量别只在App启动时,毕竟用户可以先启动App然后再打开外挂。另外,检测时也可以顺带把觉得可以的App信息也上报到后台,用于完善黑名单。

当然,这种 检测到就不给用的策略 有些过于粗暴,有时还可能造成误伤,毕竟应用包名只要不上架市场,随便起啊,你封一次我改一次,所有还得从App自身触发去防御~


② 重写TextView的findViewsWithText()屏蔽文案检查

我们知道AccessibilityServices中定位节点的两种常规方式,一个是id,一个是根据text文本,后者 findAccessibilityNodeInfosByText() 最终调用的实际是View的 findViewsWithText()。只需对这个方法进行重写即可屏蔽文案检查,代码示例如下:

class DefensiveTextView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
    AppCompatTextView(context, attrs) {
    override fun findViewsWithText(
        outViews: ArrayList<View>?,
        searched: CharSequence?,
        flags: Int
    ) {
        outViews?.remove(this)
    }
}

③ 屏蔽点击事件

上面是屏蔽找的,接着是屏蔽点击时间的,因为AccessibilityServices执行点击最终会调用View的OnClickListener回调onClick()。所以,一种最直接的方法就是自定义View,然后 用onTouch()替换onClick()

除此之外还有另外一种方法 → 重写performAccessibilityAction()返回true,以此忽略掉AccessibilityService传递过来的事件。实现方式的话,除了自定义View重写外,还可以调用 setAccessibilityDelegate() 对控件进行设置,直接给出设置的扩展代码,用时直接调就好:

// 控件是否屏蔽无障碍相关
fun View.disableAccessibility(disable: Boolean = true) {
    if (!disable) {
        this.accessibilityDelegate = null
    } else {
        this.accessibilityDelegate = object : View.AccessibilityDelegate() {
            override fun performAccessibilityAction(
                host: View?,
                action: Int,
                args: Bundle?
            ): Boolean {
                // performAction方法触发的行为,拦截View响应无障碍服务模拟事件的API
                return true
            }

            override fun sendAccessibilityEvent(host: View?, eventType: Int) {
                // 篡改或屏蔽View发送的无障碍事件
            }

            override fun onInitializeAccessibilityEvent(
                host: View?,
                event: AccessibilityEvent?
            ) {
                // 阻止View生成AccessibilityNodeInfo, 从而防止无障碍抓取到内容
            }

            override fun onInitializeAccessibilityNodeInfo(
                host: View?,
                info: AccessibilityNodeInfo?
            ) {
                // 阻止View发送出去的AccessibilityEvent
            }

            override fun dispatchPopulateAccessibilityEvent(
                host: View?,
                event: AccessibilityEvent?
            ): Boolean {
                // 阻止 AccessibilityEvent 向子 View 传递
                return false
            }

            override fun onRequestSendAccessibilityEvent(
                host: ViewGroup?,
                child: View?,
                event: AccessibilityEvent?
            ): Boolean {
                // 阻止子View请求发送无障碍事件消息
                return false
            }
        }
    }
}

// 调用处:
button.disableAccessibility()

主要是重写setAccessibilityDelegate(),其它方案可按需增删~

对了,泼个冷水哈,上述两种屏蔽方式,都可以通过上一节教的 手势模拟点击 来破解~


④ 主动发送Event干扰

我们都知道AccessibilityServices的玩法其实就是:监听目标APP发出的AccessibilityEvent来执行相应操作。

而在APP里,其实可以调用View的 sendAccessibilityEvent() 来主动发送Event,所以一种防御的思路就是闲来无事发几个Event,尝试干扰外挂程序的正常逻辑。代码示例如:

textview.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED)
button.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOWS_CHANGED)

不过,这个操作其实有些鸡肋,毕竟收到Event后都是检测页面是否有特定因素,然后再执行下一步的。

道高一尺,魔高一丈,上面提到的防御技巧都是有办法绕过的,比如你屏蔽了文案检查,那我就OCR文字识别,甚至根据图片匹配。个人感觉还得是 风控,采集用户操作记录,检测到反人类的异常行为时告警,如每次点击都是点一个坐标,如页面操作时间超短等等。


0x4、小结

本节对 AccessibilityService保活和防御 相关进行了学习,相信大家学完也会有所裨益。关于AccessibilityService的知识点,就差一篇 源码解读 了,但不影响我们学习开发自动化脚本,所以将会在本专栏末尾进行讲解。而下节会讲一下使用 AccessibilityService 的最佳拍档 —— Android悬浮框,它两的关系可谓是:吃面不吃蒜,香味少一半。敬请期待~


参考文献