白盒测试 用 hook 做测试之静态 hook

fenfenzhong · 2017年04月14日 · 最后由 大大灰灰狼 回复于 2017年04月17日 · 3789 次阅读
本帖已被设为精华帖!

最近在调研各种 hotfix 以及动态加载的技术,在这些技术层出不穷的时候,身为测试,为了拿到我们想要的信息,需要掌握些什么?

引言

相信经常看文档(尤其是官方文档)的同学一定会有一个体会,文档总是由某一个 topic 出发,然后在介绍这个 topic 的时候包含了各种各样的引用、链接,如果有某一个工具可以一次性打开所有这些深层嵌套的链接,那它们的样子一定会像是一个图(因为还有循环的嵌套),每个节点都有多条有向边。那如果想要搞清楚这个主题说的是什么,我们是要对这个图进行怎样的遍历呢?深度优先还是广度优先?我建议先广度,再深度。如果现在这个 topic 就是 hotfix ,那从广度上我觉得它应该至少包含以下系列:

  1. hook
  2. 动态加载,插件化
  3. 字节码插桩
  4. 编译(dex 分包,proguard 混淆等)

今天就来说下 hook
参考资料:
http://weishu.me/2016/01/28/understand-plugin-framework-overview/
https://www.ibm.com/developerworks/cn/java/j-lo-classloader/
http://blog.csdn.net/shenyunsese/article/details/11737179

keyword

关于 hook 的关键字,非下面几个莫属:

  1. Reflection
  2. AOP
  3. Proxy

Reflection

不细说了,网上资料一大把,概括如下:

  1. java 程序经过 javac,编译为.class 字节码文件,这是一种连续的,紧密型的二进制字节码,它存储于磁盘上
  2. 然后 Java VM(代指各种虚拟机),按照自己的规范和顺序将 1 中的字节码读取到内存中的各个位置并划分区域,这个过程,就称为 class loading
  3. 反射,关键是靠类加载器(ClassLoader),而类加载器的职责,是在运行时根据一个指定的类的名称,找到或者生成其对应的字节码,然后从这些字节码中定义出一个 java 类,即 java.lang.Class 的一个实例
  4. 拿到这个 Class 后,我们可以用它来构造一个对象,也可以获取所有它里面定义的变量,方法,按照需要 get/set

AOP(Aspect Oriented Programming)

AOP,就跟 OOP(Object Oriented Programming,面向对象)一样,它俩都是一种 编程思想,但是面向对象目的模块化,封装,即一个对象完成设计出来,是为了完成它分内的事,但如果各个 module 都只做自己的事,突然有一个类似性能监控的需求下来,它要作用于所有的模块,怎么办?

就像上图,logging 应该是所有模块都要兼备的功能。可能有人会说,可以把 logging 设计为一个 interface,现有的所有 module 实现该 interface 就好了。那如果之前系统已经有一千个 module 怎么办?难道要手动去改一千个类?又或者之后系统需要扩展,再加两千个类,每次新写一个类都得实现这个 interface?

这个 logging,又或者具有 logging 属性的其他类似 module,它就是一个 aspect,需要贯穿所有目标 module,在不考虑现在量级和以后扩展量级的基础上怎么实现?这大概就是 AOP 想要描述的事情,也是它和 OOP 的区别所在,AOP 分下面两种

  1. 运行时 AOP:主要是动态的修改某些方法的行为,比较有名的 AOP 框架有 Xposed 和 Dexposed
  2. 编译时 AOP:主要是打包过程中动态修改 .class 字节码,也可以理解为字节码 hook

Proxy

Proxy(代理),这个不用说了,大家都很熟悉了,代理就相当于站在原始 client 和原始 server 之间的一个中间人,原始的输入和输出全部都经过它,它可以帮助原 client 处理事情,那么它有以下这些特点:

  1. 从输入到输出,精确的控制流程
  2. 基于第 1 点,可以理解为对原有功能的增强实现
  3. 静态代理:用一个明确的代理类实现
  4. 动态代理:在运行时生成的代理实例

这里的静态和动态指的是代理 instance 的生成时机,和『透明代理,反向代理,动态代理』这些不是一码事

静态代理

例子来源于http://weishu.me/2016/01/28/understand-plugin-framework-proxy-hook/, 略有改动
首先得有一个接口:

public interface Shopping {
    Object[] doShopping(long money);
}

原始的实现如下,自己拿钱去购物:

public class ShoppingImpl implements Shopping {
    @Override
    public Object[] doShopping(long money) {
        System.out.println("逛淘宝 ,逛商场,买买买!!");
        System.out.println(String.format("花了%s块钱", money));
        return new Object[] { "鞋子", "衣服", "零食" };
    }
}

代理的实现如下:

public class ProxyShopping implements Shopping {

    Shopping base;

    ProxyShopping(Shopping base) {
        this.base = base;
    }

    @Override
    public Object[] doShopping(long money) {

        if (money < 100) {
          return null
        } else {
         // 先黑点钱(修改输入参数)
        long readCost = (long) (money * 0.5);

        System.out.println(String.format("花了%s块钱", readCost));

        // 帮忙买东西
        Object[] things = base.doShopping(readCost);

        // 偷梁换柱(修改返回值)
        if (things != null && things.length > 1) {
            things[0] = "被掉包的东西!!";
        }

        return things;
      }
    }

这个栗子很棒,总结一下:

  1. 代理类(ProxyShooping)总是要和原始类(ShoppingImpl)实现同样的接口(Shopping)
  2. 代理类的构造函数需要接受一个原始类的实例,具体干事儿的过程,还是原始类去实现的
  3. 不过代理类可以在实际方法执行的前后,做很多有意思的事(比如黑钱,黑物品)

静态 Proxy VS Decorator

等等,上面的第 2 点和第 3 点,怎么感觉和装饰器(decorator)这么像,实际上,本来 Proxy 和 Decorator 就是比较类似的两种设计模式,但它俩的区别如下,比较微妙:

  1. proxy 主要用于访问控制和流程控制,比如上述的,当传入的 money 小于 100 时,代理就不干了,黑了你的所有钱
  2. decorator 主要用于在原始的实现前后,动态的添加其他步骤

动态代理

下篇讲

静态 hook

不涉及到动态的创建代理对象的劫持行为,我称它为静态 hook,下面给大家再举两个栗子:
相信很多 app 都有这么一种功能,打开 app 第 n 次,弹出一个框让用户给个好评;又或者,进行了某个操作 n 次,触发某个 dialog。像这种弹框在 UI 自动化测试中是比较难处理的,因为无法确定它出现的时机,那我们是否可以打出测试包时 hook 掉它,让它永远也不可能为 n

一般这些值都存储在 app 的 SharedPreference 中,来看下怎么在程序中获取到关联的 SharedPreference:

PreferenceManager.getDefaultSharedPreferences(context);

SharedPreference 就是一个键值对,里面提供了很多方法,可以根据某个键 get 或 set 其对应的 value,比如:

getDefaultSharedPreferences(context).getString(key, defaultValue)
getDefaultSharedPreferences(context).edit().putString(key, value).apply()

为了加深 Reflection 的理解,我们可以定义如下的方法:

public final class HookHelper {

    public static void hookprefs(Context ctx)
    {
        try {
            final String PREF_LAUNCH_TIMES = "pref_launch_times";
            Class<?> prefsmanager = Class.forName("android.preference.PreferenceManager");

            Method getprefs = prefsmanager.getDeclaredMethod("getDefaultSharedPreferences", Context.class);
            SharedPreferences prefs = (SharedPreferences) getprefs.invoke(null, ctx);

            int rawLaunchTimes = prefs.getInt(PREF_LAUNCH_TIMES, 0);
            Log.d("hooked launchTimes", "old launch time is " + rawLaunchTimes);

            // 修改启动次数永远为3
            prefs.edit().putInt(PREF_LAUNCH_TIMES, 3).apply(); // 不要忘了 apply
        }catch (Exception e) {
            throw new RuntimeException("Hook Failed", e);
        }
    }

ok,然后在 application 的 onCreate() 方法里面,加上一句:

HookHelper.hookprefs(this)

这样启动次数永远都是 3,可以在 adb logcat 中验证

静态代理,hook activity 启动

一个例子说服力不强,那下面我们来 hook 一个 activity 的启动,我们的 MainActivity 代码如下,很简单,就一个按钮,点击之后 startActivity,跳转到 XXX 主页

public class MainActivity extends Activity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        try {
            // 在这里进行Hook
            HookHelper.attachContext(this);
        } catch (Exception e) {
            e.printStackTrace();
        }

        Button tv = new Button(this);
        tv.setText("测试界面");

        setContentView(tv);

        tv.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Intent intent = new Intent(Intent.ACTION_VIEW);
                intent.setData(Uri.parse("http://www.XXX.com"));
                startActivity(intent);
            }
        });
    }
}

为了达到 hook 的效果,我们必须看下 startActivity 的源码,从源头上下手,发现调用链如下:

@Override
    public void startActivity(Intent intent) {
        this.startActivity(intent, null);
    }

然后

@Override
    public void startActivity(Intent intent, @Nullable Bundle options) {
        if (options != null) {
            startActivityForResult(intent, -1, options);
        } else {
            // Note we want to go through this call for compatibility with
            // applications that may have overridden the method.
            startActivityForResult(intent, -1);
        }
    }

然后,走到startActivityForResult,真正执行的是:

Instrumentation.ActivityResult ar =
                mInstrumentation.execStartActivity(
                    this, mMainThread.getApplicationThread(), mToken, this,
                    intent, requestCode, options);

即调用了 mInstrumentationexecStartActivity 方法,这个mInstrumentation是 Activity 类里一个 Instrumentation 型的私有变量,如果我们能 hook 掉这个变量,将它替换成我们自定义的某个实例,而这个实例改写了 execStartActivity 方法,是不是就达到目的呢?
我们在 onCreate 里设置了一段 hook,来看看它的实现:

public class HookHelper {

    public static void attachContext(Activity obj) throws Exception{
        // 先获取到当前的ActivityThread对象

        Class<?> currentActivity = Class.forName("android.app.Activity");
        Field instrumentationField = currentActivity.getDeclaredField("mInstrumentation");
        instrumentationField.setAccessible(true);
        Instrumentation mInstrumentation = (Instrumentation) instrumentationField.get(obj);
        Instrumentation evilInstrumentation = new EvilInstrumentation(mInstrumentation);
        instrumentationField.set(obj, evilInstrumentation);
    }
}

ok,确实是给mInstrumentation赋了值,值为我们自定义的 EvilInstrumentation,这个类的构造函数是接受了一个旧的 Instrumentation 变量(请结合 Proxy 的总结),
来看下实现:

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之前, 输出原始信息!
        long startTime = 0L;
        Log.e(TAG, "\nhi baby,you are hooked!, 原始参数如下: \n" + "who = [" + who + "], " +
                "\ncontextThread = [" + contextThread + "], \ntoken = [" + token + "], " +
                "\ntarget = [" + target + "], \nintent = [" + intent +
                "], \nrequestCode = [" + requestCode + "], \noptions = [" + options + "]");

        // 开始调用原始的方法, 也可决定是否调用
        // 由于这个方法是隐藏的,因此需要使用反射调用;首先找到这个方法
        try {
            intent.setData(Uri.parse("http://www.douban.com"));
            startTime = System.currentTimeMillis();
            Log.d(TAG,"before exec : " + startTime);
            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");
        } finally
        {
            long afterTime = System.currentTimeMillis();
            long totalTime = afterTime - startTime;
            Log.d(TAG,"after exec :" + afterTime);
            Log.d(TAG,"total exec :" + totalTime);
        }
    }
}

使用这种这个 EvilInstrumentation 就是一个代理,它来做一些 hook 的事:

  1. 打印出原始的参数信息
  2. 修改 intent,原来是导向 XXX, 修改后是导向豆瓣主页
  3. 打印出了方法的执行时间
  4. 根据传入时的 mBase,执行原 execStartActivity 方法,当然你可以不执行,而去做其他的事

build 看下效果,果然成功啦!日志也打出来了!

总结

如果我们把这一段放在项目中所有 Activity 的基类中,比如 BaseActivity,加入一些有 logging 属性的监控代码,那么是不是 AOP 的一种体现呢?
目前这样写虽然目的性达到了,但是对于代码的侵入性还是很强的,从 Activity 的成员变量下手,是能 hold 住所有的 activity,但其他的组件我们有办法兼顾吗?
下篇讲

最重要的

知道为什么 hook 后跳转的是豆瓣主页吗,因为我们在招 iOS 的测试开发同学,详情见:https://jobs.douban.com/#position-cskfgcs,欢迎私

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 4 条回复 时间 点赞
恒温 将本帖设为了精华贴 04月15日 14:33

有技术的招聘贴。

点赞,最近在学 assertJ

不错, 你这篇文章算是点到了测试架构师的关键技能了. 难得有如此的领悟啊

这么硬的广告插入我竟然还是买单了 😂

fenfenzhong 用 hook 做测试之 Binder 跨进程通信 中提及了此贴 05月04日 19:33
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册