Android hook, 以及对插件框架如何实现的开发精要

Hook的概念

*所谓对API的Hook, 其实就是对方法的动态替换. *
采用代理的方式, 创建一个新的对象, 其内部封装原始对象,通过这种方式,可以修改这个方法的参数以及返回值, 或是在方法中新打印一行log, 达到 方法增强 的目的.

实现方式

在运行时, 采用反射的方式, 用自己新建的代理对象把原始对象给替换掉.
代理对象本质上还是通过原始对象去干事.

对Context.startActivity的hook.

启动Activity是最常见的操作, Context.startActivity的真正实现是在ContextImpl.java中.

// ContextImpl.java

    @Override
    public void startActivities(Intent[] intents, Bundle options) {
        warnIfCallingFromSystemProcess();
        if ((intents[0].getFlags()&Intent.FLAG_ACTIVITY_NEW_TASK) == 0) {
            throw new AndroidRuntimeException(
                    "Calling startActivities() from outside of an Activity "
                    + " context requires the FLAG_ACTIVITY_NEW_TASK flag on first Intent."
                    + " Is this really what you want?");
        }
        mMainThread.getInstrumentation().execStartActivities(
                getOuterContext(), mMainThread.getApplicationThread(), null,
                (Activity) null, intents, options);
    }

可以看到这个API真正的实现是在ActivityThread的成员变量
Instrumentation mInstrumentation;的 execStartActivities()方法.

所以hook的思路就是实现一个Instrumentation的代理类, 在代理类中提供一个新的execStartActivities()方法的实现,
用这个代理类的对象,把ActivityThread的成员变量
Instrumentation mInstrumentation给替换掉.

@hide
public final class ActivityThread {
    Instrumentation mInstrumentation;

    public Instrumentation getInstrumentation() {
        return mInstrumentation;
    }

}

ActivityThread是一个隐藏类,我们需要用反射去获取,代码如下:

// 先获取到当前的ActivityThread对象
Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
Method currentActivityThreadMethod = activityThreadClass.getDeclaredMethod("currentActivityThread");
currentActivityThreadMethod.setAccessible(true);
Object currentActivityThread = currentActivityThreadMethod.invoke(null);

拿到这个currentActivityThread对象之后,我们需要修改它的mInstrumentation这个字段为我们的代理对象.

新建Instrumentation的代理类.

public class EvilInstrumentation extends Instrumentation {

    private static final String TAG = "EvilInstrumentation";

    // ActivityThread中原始的对象, 保存起来
    Instrumentation mBase;

    public EvilInstrumentation(Instrumentation base) {
        mBase = base;
    }

    public ActivityResult execStartActivity(
            Context who, IBinder contextThread, IBinder token, Activity target,
            Intent intent, int requestCode, Bundle options) {

        // Hook之前, XXX到此一游!
        Log.d(TAG, "\n执行了startActivity, 参数如下: \n" + "who = [" + who + "], " +
                "\ncontextThread = [" + contextThread + "], \ntoken = [" + token + "], " +
                "\ntarget = [" + target + "], \nintent = [" + intent +
                "], \nrequestCode = [" + requestCode + "], \noptions = [" + options + "]");

        // 开始调用原始的方法, 调不调用随你,但是不调用的话, 所有的startActivity都失效了.
        // 由于这个方法是隐藏的,因此需要使用反射调用;首先找到这个方法
        try {
            Method execStartActivity = Instrumentation.class.getDeclaredMethod(
                    "execStartActivity",
                    Context.class, IBinder.class, IBinder.class, Activity.class, 
                    Intent.class, int.class, Bundle.class);
            execStartActivity.setAccessible(true);
            return (ActivityResult) execStartActivity.invoke(mBase, who, 
                    contextThread, token, target, intent, requestCode, options);
        } catch (Exception e) {
            // 某该死的rom修改了  需要手动适配
            throw new RuntimeException("do not support!!! pls adapt it");
        }
    }
}

完整的代码如下:

package com.ahking.hookdemo;

import android.app.Instrumentation;
import android.content.Intent;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.view.View;

import java.lang.reflect.Field;
import java.lang.reflect.Method;

import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK;

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        try {
            initHook();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public void launchSecondActivity(View view) {
        Intent intent = new Intent(this, SecondActivity.class);
        intent.setFlags(FLAG_ACTIVITY_NEW_TASK);
        this.getApplicationContext().startActivity(intent);
    }

    private void initHook() throws Exception{

        // 先获取到当前的ActivityThread对象
        Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
        Method currentActivityThreadMethod = activityThreadClass.getDeclaredMethod("currentActivityThread");
        currentActivityThreadMethod.setAccessible(true);
        Object currentActivityThread = currentActivityThreadMethod.invoke(null);


        // 拿到原始的 mInstrumentation字段
        Field mInstrumentationField = activityThreadClass.getDeclaredField("mInstrumentation");
        mInstrumentationField.setAccessible(true);
        Instrumentation mInstrumentation = (Instrumentation) mInstrumentationField.get(currentActivityThread);

        // 创建代理对象, 构造时把原始对象作为参数传进去.
        Instrumentation evilInstrumentation = new EvilInstrumentation(mInstrumentation);

        // 偷梁换柱——用代理对象替换原始对象
        mInstrumentationField.set(currentActivityThread, evilInstrumentation);

    }
}




log输出如下:

com.ahking.hookdemo D/ahking: 执行了startActivity, 参数如下: 
                         who = [android.app.Application@41e6eb20], 
                         contextThread = [android.app.ActivityThread$ApplicationThread@41e68f50], 
                         token = [null], 
                         target = [null], 
                         intent = [Intent { flg=0x10000000 cmp=com.ahking.hookdemo/.SecondActivity }], 
                         requestCode = [-1], 
                         options = [null]

基于这样的思路, 插件的原型就出来了.
  1. 在host app的AndroidManifest.xml中, 预先注册一个Activity, 比如叫PluginActivity.
  2. 通过在host app中, hook startActivity(intent)方法, 当host app要启动plugin app中的某个Activity时(需要明确指出要启动页面的完整包名和类名), 在hook了的startActivity中, 把要启动的页面修改为PluginActivity, 这样AMS就不会报错了.
  3. AMS回调host app进程中的ActivityThread的 handleLaunchActivity(),
    这个方法负责创建Activity的对象, 然后依次调用它的onCreate(), onStart()和onResume().
public final class ActivityThread {

    private void handleLaunchActivity(ActivityClientRecord r, Intent customIntent) {
        Activity a = performLaunchActivity(r, customIntent);
    }

    private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {

        ComponentName component = r.intent.getComponent();


        Activity activity = null;
        try {
            java.lang.ClassLoader cl = r.packageInfo.getClassLoader();
            activity = mInstrumentation.newActivity(
                    cl, component.getClassName(), r.intent);
        } catch (Exception e) {
            if (!mInstrumentation.onException(activity, e)) {
                throw new RuntimeException(
                    "Unable to instantiate activity " + component
                    + ": " + e.toString(), e);
            }
        }

        mInstrumentation.callActivityOnCreate(activity, r.state);

}

这行代码很关键, 通过Instrumentation创建具体Activity的对象, 这里component.getClassName()的值必然是AMS传进来的PluginActivity.

            activity = mInstrumentation.newActivity(
                    cl, component.getClassName(), r.intent);

我们可以hook Instrumentation.newActivity()这个方法, 当发现传进来的参数是PluginActivity时, 并不去创建PluginActivity的对象, 而修改成去创建 plugin app中的Activity的对象, 进而调用这个对象的onCreate(), onStart()和onResume().

如何去创建出 plugin app中的Activity的对象呢? 这就要通过DexClassLoader类.

Instrumentation的原始方法:

public class Instrumentation {

    public Activity newActivity(Class<?> clazz, Context context, 
            IBinder token, Application application, Intent intent, ActivityInfo info, 
            CharSequence title, Activity parent, String id,
            Object lastNonConfigurationInstance) throws InstantiationException, 
            IllegalAccessException {
        Activity activity = (Activity)clazz.newInstance();
        ActivityThread aThread = null;
        activity.attach(context, aThread, this, token, 0, application, intent,
                info, title, parent, id,
                (Activity.NonConfigurationInstances)lastNonConfigurationInstance,
                new Configuration(), null, null);
        return activity;
    }
}

可以看到在原始方法中, 是通过ClassLoader的newInstance()方法, 去创建Activity的对象.

用DexClassLoader类, 可以加载一个apk文件中的classes.dex.

例如这段代码:

    DexClassLoader classloader = new DexClassLoader("apkPath",
            optimizedDexOutputPath.getAbsolutePath(),
            null, context.getClassLoader());
    Class<?> clazz = classloader.loadClass("com.plugindemo.test");
    Object obj = clazz.newInstance();
    Class[] param = new Class[2];
    param[0] = Integer.TYPE;
    param[1] = Integer.TYPE;
    Method method = clazz.getMethod("add", param);
    method.invoke(obj, 1, 2);

我们可以用DexClassLoader这个类把插件apk中的classes.dex加载进来, 然后调用它的loadClass(“完整的类名”)方法把要启动的Activity类加载进来, 再调用Class类的newInstance()创建出插件中Activity的对象, 进而再通过调用mInstrumentation.callActivityOnCreate(activity, r.state);启动这个Activity.

这样就完成了对插件中页面的启动工作, 在host app中要做的, 就是要明确指定好要启动页面的完整包名和类名.

用hook机制解决的一个实际问题.

来launcher这边的公司后, 同事碰到这样一个棘手问题, 一直没法解决.
在mediaV广告模拟点击后, 出现sdk中使用deeplink打开别的app页面, 比如京东. 导致这个功能一直无法上线.
我使用上面的代码, 对startActivity() API进行hook, 把京东这样的intent给过滤掉, 这样就完美解决了这个棘手问题.

    public ActivityResult execStartActivity(
            Context who, IBinder contextThread, IBinder token, Activity target,
            Intent intent, int requestCode, Bundle options) {

        String intentInfo = intent.toString().toLowerCase();
        if (sMockClick > 0 && (intentInfo.contains("akactivity") || intentInfo.contains("jdmobile"))) {
            sMockClick--;
            Log.i(TAG, "ignore it triggered by mediav");
            return null;
        }

        try {
            Method execStartActivity = Instrumentation.class.getDeclaredMethod(
                    "execStartActivity",
                    Context.class, IBinder.class, IBinder.class, Activity.class,
                    Intent.class, int.class, Bundle.class);
            execStartActivity.setAccessible(true);
            return (ActivityResult) execStartActivity.invoke(mBase, who,
                    contextThread, token, target, intent, requestCode, options);
        } catch (Exception e) {
            throw new RuntimeException("3rd party rom modify this maybe, by ahking");
        }
    }

所以说, 有些知识平时多积累一些, 在一些关键时刻就能派上用场, 像activity的启动流程, hook的实现, 当初学的时候看似无用, 学不学看似对实际的开发并没有任何的意思, 但如果当初不学, 今天这样的问题, 打死也想不到可以用这样的方式去解决.

-------DONE.-------------

refer to:
Android插件化原理解析——Hook机制之动态代理
http://weishu.me/2016/01/28/understand-plugin-framework-proxy-hook/?nsukey=r%2BreMOlnWhDVfOrGukrJH1b%2FDJ9hDbJ0u4hfr6EQY2YIT4RCeJwqR20Lv0rQPVcPyLN4eX%2BgjW3k9fluG6CRgaUj1GyMa1GlVxN1F7%2FU%2FhiikosDgBCklABQCWbrFuXXHL0Q9QnQGDLOcL3demC82ZPcSTFjQrhrm8fEYqxTTxyn9JRzzsfCpZ3CG%2Bn6Z46s

http://zjmdp.github.io/2014/07/22/a-plugin-framework-for-android/

/home/wangxin/src/github/hookDemo (demo代码的位置)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,905评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,140评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,791评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,483评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,476评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,516评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,905评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,560评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,778评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,557评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,635评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,338评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,925评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,898评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,142评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,818评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,347评论 2 342

推荐阅读更多精彩内容