打造一款具有创造力且高度可复用的 android 自动化测试工具 (qq 群 :608824162)

主页入口 请点我
https://github.com/zhangzhao4444/XMonkey

优势

  1. UI 自动化与 Fuzz 结合,边遍历边测容错!
  2. 支持多种 Fuzz,如 Intent fuzz、Json fuzz、text fuzz、provider mock、gps mock 等
  3. 多个黑科技,如防跳出、崩溃捕获、弹窗屏蔽、权限绕过、ssl 绕过等

如何使用

  1. 安装 Xposed Installer(见#1 楼
    http://repo.xposed.info/module/de.robv.android.xposed.installer
  2. 安装 Xmonkey,在 xposed 中激活 xmonkey,然后重启手机 
  3. 启动 Xmonkey,配置策略,选择待测 app,start test,悬浮窗点击开始跑 monkey(亦可不跑 monkey 手动触发 fuzz)
  4. monkey 过程中点开悬浮窗即暂停 monkey,回到 Xmonkey 可查看 activity 覆盖率、崩溃堆栈

各种 Fuzz

前提 Enable Injection(启动注入)

*Provider mock: 对 provider 的 query 接口进行 hook,根据传入的 uri 类型进行对应的伪造以及 null。如 image、audio

findAndHookMethod("android.content.ContentResolver", loader, "query",Uri::class.java,..,object : XC_MethodHook() {
    @Throws(Throwable::class)
    override fun afterHookedMethod(param: XC_MethodHook.MethodHookParam?) {
        val uri = param.args[0] as Uri
        val cursor = param.result as Cursor
        ContentsProcessor.solve(context, uri, cursor)
    }
})

*Json fuzz:对 HttpURLConnectionImpl 及 okhttp3 的 OkHttpClient 进行 hook,对服务端返回做延迟、非 json、空 json、null、xss 等 fuzz(支持 https fuzz)

......
if (Build.VERSION.SDK_INT >=19){
    findAndHookMethod("com.android.okhttp.internal.http.HttpURLConnectionImpl", loader, "getOutputStream", hook)
    findAndHookMethod("com.android.okhttp.internal.http.HttpURLConnectionImpl", loader, "getInputStream", hook)
}else{
    findAndHookMethod("libcore.net.http.HttpURLConnectionImpl", loader, "getOutputStream", RequestHook)
}

val hook= object : XC_MethodHook() {
    @Throws(Throwable::class)
    override fun afterHookedMethod(param: XC_MethodHook.MethodHookParam?) {
        ......
        val code = urlConn.responseCode
         if (code == 200) {
             val body = AppUtil.getBytesByInputStream(result)
             oldrespose = AppUtil.getStringByBytes(body)
             fakerespose = FuzzingDroid.getResposedValue()
             var fakeresult:InputStream? = null
             when(fakerespose){
                 "" -> fakeresult = AppUtil.strToInputSteam("")
                 "{}" -> fakeresult = AppUtil.strToInputSteam("{}")
                 "1=1" -> fakeresult = AppUtil.strToInputSteam("{1=1}")
                 ......
}



上面例子 app 对 json 返回容错不严谨造成了 app crash
logcat 可看 mock 情况,红色命中 mock,绿色未命中

*Text fuzz
对界面中控件遍历,对 edit 的进行 fuzz 填充,如非法字符、超长、空 string、null、xss 等

fun fillTextFuzz(context: Context, views: List<View>) {
    for (i in views.indices) {
        val et = views[i] as EditText
        val value = InjectorManager.mockInput(et.inputType)
        et.setText(value)
    }
}


logcat 可看 mock 情况

*Gps Mock(注意与 Json Mock 互斥)
对 gps 相关模块进行 hook 并返回伪造的 location

override fun hook(loader: ClassLoader) {
    findAndHookMethod("android.telephony.TelephonyManager", loader, "getCellLocation",  object : XC_MethodHook() {
        @Throws(Throwable::class)
        override fun afterHookedMethod(param: XC_MethodHook.MethodHookParam?) {
            val gsmCellLocation = GsmCellLocation()
            gsmCellLocation.setLacAndCid(lac, cid)
            param?.result = gsmCellLocation
        }
    })

* 静态 Intent Fuzz(注意与动态 Intent Fuzz 互斥)
start test 后对所有 exported 的组件遍历进行 intent fuzz 攻击,如 null、非法序列化 Intent 等
静态 Intent fuzz 过程中,无需手工或 monkey 操作,等待 弹出 fuzz over 提示时表示完成测试。(测试过程中可能会引起多次崩溃)

activities?.forEach {
    if (it.exported){
        Logger.notice(context,"start activity {${it.name}} with Null Intent" )
        val intent = Intent()
        intent.component = ComponentName(packageName, it.name)
        context.startActivity(intent)
        Thread.sleep(8 * 1000)
    }
}

* 动态 Intent Fuzz(注意与静态 Intent Fuzz 互斥)

运行时 hook Intent、bundle 组件相关接口,实现运行过程中劫持并畸变 intent,如空、非法、null、空数组、非法序列等

findAndHookMethod(bundle, loader, "getBoolean",String::class.java, Boolean::class.javaPrimitiveType, object : XC_MethodHook() {
    @Throws(Throwable::class)
    override fun afterHookedMethod(param: MethodHookParam?) {
        val value = FuzzingDroid.getBooleanValue()
        Logger.log("Bundle:Mock key-boolean: ${param!!.args[0]}-{ new=$value old=${param.result} }")
        param.result =  value
     }
})

findAndHookMethod(intent, loader, "getData", object : XC_MethodHook() {
    @Throws(Throwable::class)
    override fun afterHookedMethod(param: MethodHookParam?) {
        val value = FuzzingDroid.getDataValue()
        Logger.log("Intent:Mock data-uri: data-{ new=$value old=${param?.result} }")
        param?.result =  value
    }
})

该攻击畸变程度较高,app 可能出现大量崩溃,建议单独使用。

难点及分析解决

Hook 的方法很容易,但难点是从一系列路径中寻找到一个合适 Hook 的点。
a. Monkey 如何跨进程注入

XMonkey 中事件注入是通过调用 Instrumentation.sendPointerSync 来实现的。
但是当调 Instrumentation.sendPointerSync 执行跨进程点击时,会报如下错误:

由错误可知:Premission denied,inject event from a to b。权限拒绝了
https://www.cnblogs.com/slgkaifa/p/6727510.html
如何解决?初步思路是 hook 某个关键点将 uid a 篡改为 b 以此绕过权限检查。


重点是这个 nativeInjectEvent,如果返回 INPUT_EVENT_INJECTION_SUCCEEDED 则注入成功,继续跟进这个 native func


最终执行 hasInjectionPermission 来进行权限检查,并当 uid==0 也就是 root 时返回 policyFlags |= POLICY_FLAG_TRUSTED,从而后续进一步完成事件注入。

结论方案:选取这个关键的 native func 入口作为 hook 点 伪造 uid 为 0,从而绕过系统的权限检查

findAndHookMethod("com.android.server.input.InputManagerService", loader, "nativeInjectInputEvent",
    Int::class.javaPrimitiveType, android.view.InputEvent ::class.java, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, object : XC_MethodHook(){
    @Throws(Throwable::class)
    override fun beforeHookedMethod(param: XC_MethodHook.MethodHookParam?) {
        if (param == null) return
        if("${param.args[3]}" == UID) param.args[3] = 0
    }
})

注:该 uid 为被测 app 的 id,且这个 hook 需要重启手机生效,所以测试时选取完被测 app 后需要重启一次手机来完成跨进程注入。另注不同 framework 对应的这个 nativeInjectInputEvent func 不同。

b. 屏蔽系统弹窗
为何需要屏蔽弹窗?出于以下几点原因:

  1. monkey 过程中减少系统干扰,以防跳出待测 app,如系统更新升级、键盘选择、权限提示等。
  2. app 崩溃或 anr 时并不希望弹出窗干扰。
    如何做:
    以 app anr 系统弹 无响应窗为例,crashApplication 中给 mUihandler 发 msg

    new 一个 appnotresponedingdialog

    经过一系列跟进找到该 appnotresponedingdialog 实际是 dialog 的子类,所以我们的思路是 hook dialog,重写 show func
val hook = object :XC_MethodReplacement(){
    @Throws(Throwable::class)
     override fun replaceHookedMethod(param: MethodHookParam): Any? {
         return null
     }
}

hookAllMethods(Dialog::class.java, "show", hook)
......

注: 将 show func 替换为空实现,凡是 Dialog 的子类均会受此影响。此 hook 也需重启手机生效,故更改 “屏蔽系统弹窗” 选项后需重启。另注:该屏蔽同样会影响安装包提示及权限提示。

c. 崩溃捕获

上图是大致的崩溃处理流程,左侧是 app 端处理崩溃,右侧则是系统端。由此可以得出存在两种 Hook 方案:

  1. hook App 端,UncaughtExceptionHandler 的 uncaughtException
  2. hook 系统端,crashApplication
// UncaughtExceptionHandler
findAndHookMethod(classHandler, "uncaughtException", Thread::class.java, Throwable::class.java, object : XC_MethodHook() {
    @Throws(Throwable::class)
    override fun beforeHookedMethod(param: MethodHookParam) {
        .....
    }
})

// Handle Crash
findAndHookMethod("com.android.server.am.ActivityManagerService", loader, "handleApplicationCrash", IBinder::class.java, ApplicationErrorReport.CrashInfo::class.java, object : XC_MethodHook() {
    @Throws(Throwable::class)
      override fun beforeHookedMethod(param: XC_MethodHook.MethodHookParam) {
          ......
       }
 })

将以上两种方案均实现后调试,都可以正常 hook 到 app 的崩溃,但因为屏蔽了系统崩溃弹窗,在系统处理流程上存在等待该弹窗用户反馈的一个超时时延(大于 10 秒),显然这是无法接受的。如何处理?

最初的思路是以下 3 种,权衡之后选了 3:

  1. 修正弹窗 show 的 hook,改为默认自动选择确定(考虑默认确定的话也会影响其他非崩溃的弹窗,未知情况较多故舍弃)
  2. 仅完成 app 端崩溃处理,然后做截断,阻止其系统崩溃处理(担心会影响 app 自身的崩溃流程,没有详细研究)
  3. 系统崩溃处理链上寻找如何绕过等待超时
    跟进下 crashApplication 的弹窗以及超时逻辑 其中调用 mUihander.sendMessage 来创建崩溃弹窗,并阻塞等待用户选择 但往上看,如果存在 activityController 时,可以将崩溃流程引入 activityController,从而绕过这个崩溃弹窗。于是我们现在需要做的是构建一个 activityController,并把它 set 到 ActivityManager 中。
internal class myActivityController: IActivityController.Stub() {
@Throws(RemoteException::class)
    override fun appCrashed(processName: String, pid: Int, shortMsg: String, longMsg: String, timeMillis: Long, stackTrace: String): Boolean {
        ......
    }
...
}

fun setActivityController(activityController: IActivityController?) {
    try {
        ......  
        val amClass = Class.forName("android.app.ActivityManagerNative")
        val getDefault = amClass.getMethod("getDefault")
        am = getDefault.invoke(null)
        attemptSetController(am, activityController)
    } catch (e: Throwable) {
    }
 }

@Throws(Throwable::class)
fun attemptSetController(am: Any, activityController: IActivityController?) {
     var setMethod: Method
     setMethod = am.javaClass.getMethod("setActivityController", IActivityController::class.java)
     setMethod.invoke(am, activityController)
 }

用反射的方式进行 set。 最后一点 因为做这个 set 需要 root 权限,故我们需要 hook checkpermission 来绕过

findAndHookMethod("android.app.ActivityManager", loader, "checkComponentPermission", String::class.java, ..., object : XC_MethodHook() {
    @Throws(Throwable::class)
    override fun afterHookedMethod(param: MethodHookParam?) {
    if (android.Manifest.permission.SET_ACTIVITY_WATCHER == param!!.args[0]) {
        val re = param.result as Int
        param.setResult(0)
    }
}

当 permission 为 setActivityController 时 篡改 uid 为 root 从而绕过 checkpermission

d. 其他诸多难点,篇幅原因略去

todo

  1. 支持非 root
  2. okhttp3

其他

原生 6.0 经过上千次调试,测试通过!

(android 7 测试通过,暂时 android 8.0 以上有 bug,正在修改)

支持 virtualxposed (仅部分 hook 测试通过,暂时仅支持 monkey、text fuzz、静态 intent fuzz)

update


↙↙↙阅读原文可查看相关链接,并与作者交流