如何动手制作一个简易的iOS动态执行器

11,181 阅读8分钟
原文链接: satanwoo.github.io

之前听说滴滴的DynamicCocoa是基于JavaScriptCore搞得,一直期待看到他们的真正实现,不过可能后来由于公司机密,应该不能再开源了。

借着最近开始研究JavaScriptCore的契机,我决定利用这一两天所学的JavaScript知识,在业余时间做一个简单的iOS动态执行器玩具。

题外话1:听说滴滴基于LLVM backend搞了一套中间语言解释器,不知道最后用了哪个?不过LLVM IR解释器的话,嘿嘿,还是有点意思的。

题外话2:我研究这个并不是想做iOS动态化,因为xxxxxxx。我只是纯粹想看看JavaScriptCore的一些实现而已。

效果

一张Gif图想必能最佳得展示我做的玩具,请各位大佬过目:

前置知识点

在实现我们的执行器前,我们还是要稍微要了解一下一些前置的知识点。

JSWrapper Object

大家都知道,Objective-C中的诸多类型在JavaScript的环境里是不能直接用的,需要通过JSValue进行一层包装,具体的类型转换如下图展示:

基本上图上的转换都很容易理解,唯一需要我们注意的是Wrapper Object。什么是Wrapper Object呢?

举个例子:

self.context[@"a"] = [CustomObject new]

上述代码将我们一个自定义类型CustomObject的实例以变量名a的方式注入到了JavaScript的运行环境里。但是她是怎么知道我们的定义呢,又是如何知道我们是否能调用特定的方法?

从默认的角度看,JS运行环境只会把OC中init初始化方法以及类的继承关系给同步到JS环境中(如果有JSExport我们下文说),然后这个对象会包装给一个JSWrapperValue用于JS环境中使用。而当JS环境调用OC并且涉及到这个对象的时候,JavaScriptCore会自动将其解包还原成原始的OC对象类型。

- (JSValue *)jsWrapperForObject:(id)object
{
    JSC::JSObject* jsWrapper = m_cachedJSWrappers.get(object);
    if (jsWrapper)
        return [JSValue valueWithJSValueRef:toRef(jsWrapper) inContext:m_context];

     // 注意点!!!!!!!!!!!!!!!!!!
    JSValue *wrapper;
    if (class_isMetaClass(object_getClass(object)))
        wrapper = [[self classInfoForClass:(Class)object] constructor];
    else {
        JSObjCClassInfo* classInfo = [self classInfoForClass:[object class]];
        wrapper = [classInfo wrapperForObject:object];
    }

    JSC::ExecState* exec = toJS([m_context JSGlobalContextRef]);
    jsWrapper = toJS(exec, valueInternalValue(wrapper)).toObject(exec);
    m_cachedJSWrappers.set(object, jsWrapper);
    return wrapper;
}
  • 整体分析下,就是基于一个缓存来判断是否对特定的对象或类型已经构建果Wrapper Object,没有的话就进行构建,构建过程如下:
JSClassDefinition definition;

definition = kJSClassDefinitionEmpty;
definition.className = className;
m_classRef = JSClassCreate(&definition);

[self allocateConstructorAndPrototypeWithSuperClassInfo:superClassInfo];
  • 没啥特别的,就是OC对象创建对应的JS对象,类型对类型。
  • OC类型的继承关系在JS里面通过设置Constructor和Prototype进行构建,其实就是简单的JavaScript原型链继承。

JSExport协议 & JSExportAs

JSExport协议本质上只是个Protocol标记,用于让JavaScriptCore加载那些打上这个特殊标记的类,用于特定方式的注册及初始化。

上文我们提过,默认情况下,JavaScriptCore会对象创建一个默认的Wrapper Object,但是这个对象除了简单继承关系外,也就一个按照特殊格式命令的Constructor而已:

[NSString stringWithFormat:@"%sConstructor", className]

那如果我们需要将OC环境中的方法注入到JS环境中,就需要用到JSExport协议了,这个协议在运行时会按照如下逻辑进行处理,将方法和属性进行诸如注入:

检查init方法簇的方法,并根据这么合法提供合理的

__block HashMap<String, Protocol *> initTable;
    Protocol *exportProtocol = getJSExportProtocol();
    for (Class currentClass = cls; currentClass; currentClass = class_getSuperclass(currentClass)) {
        forEachProtocolImplementingProtocol(currentClass, exportProtocol, ^(Protocol *protocol) {
            forEachMethodInProtocol(protocol, YES, YES, ^(SEL selector, const char*) {
                const char* name = sel_getName(selector);
                if (!isInitFamilyMethod(@(name)))
                    return;
                initTable.set(name, protocol);
            });
        });
    }

    for (Class currentClass = cls; currentClass; currentClass = class_getSuperclass(currentClass)) {
        __block unsigned numberOfInitsFound = 0;
        __block SEL initMethod = 0;
        __block Protocol *initProtocol = 0;
        __block const char* types = 0;
        forEachMethodInClass(currentClass, ^(Method method) {
            SEL selector = method_getName(method);
            const char* name = sel_getName(selector);
            auto iter = initTable.find(name);

            if (iter == initTable.end())
                return;

            numberOfInitsFound++;
            initMethod = selector;
            initProtocol = iter->value;
            types = method_getTypeEncoding(method);
        });

        if (!numberOfInitsFound)
            continue;

        if (numberOfInitsFound > 1) {
            NSLog(@"ERROR: Class %@ exported more than one init family method via JSExport. Class %@ will not have a callable JavaScript constructor function.", cls, cls);
            break;
        }

        JSObjectRef method = objCCallbackFunctionForInit(context, cls, initProtocol, initMethod, types);
        return [JSValue valueWithJSValueRef:method inContext:context];
    }
注入方法和属性
Protocol *exportProtocol = getJSExportProtocol();
forEachProtocolImplementingProtocol(m_class, exportProtocol, ^(Protocol *protocol){
  copyPrototypeProperties(m_context, m_class, protocol, prototype);
  copyMethodsToObject(m_context, m_class, protocol, NO, constructor);
});

而至于JSExportAs,就是做了个简单的名称映射而已,毕竟JS函数传参和OC有很大的区别:

static NSMutableDictionary *createRenameMap(Protocol *protocol, BOOL isInstanceMethod)
{
    NSMutableDictionary *renameMap = [[NSMutableDictionary alloc] init];

    forEachMethodInProtocol(protocol, NO, isInstanceMethod, ^(SEL sel, const char*){
        NSString *rename = @(sel_getName(sel));
        NSRange range = [rename rangeOfString:@"__JS_EXPORT_AS__"];
        if (range.location == NSNotFound)
            return;
        NSString *selector = [rename substringToIndex:range.location];
        NSUInteger begin = range.location + range.length;
        NSUInteger length = [rename length] - begin - 1;
        NSString *name = [rename substringWithRange:(NSRange){ begin, length }];
        renameMap[selector] = name;
    });

    return renameMap;
}

实现过程

说了那么多基础原理,下面让我们来看看具体实现流程:

类、实例和方法

在我看来,要实现一个动态化的执行环境,有三要素是必不可少的:

类(包括元类)、实例对象以及方法。

基于我们上文对于Wrapper Object的分析,我们可以构建特殊类型的Wrapper Object对这三个元素进行包装,具体就不说了,还是建议大家自行思考,基本上类似我上文分析JSWrapperObject的步骤。

除了上述三要素,我们还需要定义一个全局变量,WZGloablObject(大家可以理解为浏览器的window对象),用于拦截顶层的属性访问。

按照这个设计,大家可以自行思考下,如果是你做,你会如何继续下面的工作,文章下周随着代码一起发布吧。

Choose 调试

搞过逆向用过Cycript的朋友都知道,Cycript在调试时候有个非常方便的调试功能:Choose。该功能可以快速的帮助我们根据类名在堆上的对象全部查询返回。

这么实用的功能必须提供,我基本上直接照搬了Cycript的实现。代码很清晰,基本能够自解释其逻辑。核心基本上就是遍历每个malloc_zone,然后根据获取的vmaddress_range判断获取到的数据其类型是不是我们要的。

// 遍历zone
for (unsigned i = 0; i != size; ++i) {
    const malloc_zone_t * zone = reinterpret_cast<const malloc_zone_t *>(zones[i]);
    if (zone == NULL || zone->introspect == NULL)
        continue;
    zone->introspect->enumerator(mach_task_self(), &choice, MALLOC_PTR_IN_USE_RANGE_TYPE, zones[i], &read_memory, &choose_);
}

// 检查对象
for (unsigned i = 0; i < count; ++i) {
   vm_range_t &range = ranges[i];
   void * data = reinterpret_cast<void *>(range.address);
   size_t size = range.size;

   if (size < sizeof(ObjectStruct))
       continue;

   uintptr_t * pointers = reinterpret_cast<uintptr_t *>(data);
#ifdef __arm64__
   Class isa = (__bridge Class)((void *)(pointers[0] & 0x1fffffff8));
#else
   Class isa = reinterpret_cast<Class>(pointers[0]);
#endif
   std::set<Class>::const_iterator result(choice->query_.find(isa));
   if (result == choice->query_.end())
       continue;

   size_t needed = class_getInstanceSize(*result);
   size_t boundary = 496;
#ifdef __LP64__
   boundary *= 2;
#endif
   if ((needed <= boundary && (needed + 15) / 16 * 16 != size) || (needed > boundary && (needed + 511) / 512 * 512 != size))
       continue;
   choice->result_.insert((__bridge id)(data));
}

不过这里一大堆的511、512的数字构成的公式,实话说我不是很懂,有了解的大佬麻烦告知我一下。

类型转换

首先我们需要记住,JavaScript的基础类型如下:

- 字符串、
- 数字、
- 布尔、
- 数组、
- 对象、
- Null、
- Undefined

所以我们只要根据对应的进行转换就可以,如下所示:

  • JS字符串 <-> NSString
  • 数字 <-> NSNumber
  • 数组 <-> NSArray
  • Null <-> NSNull
  • Undefined <-> Void (仅当返回值的时候处理,否则直接抛出异常)

题外话,JavaScript里面没有什么整数和浮点数类型区分一说,所以我们可以无脑将其通过double的方式构建NSNumber

最后再来说下对对象类型的处理:

在JavaScript,任何对象都可以简单理解为包含了属性(方法)的一个包装体,如下所示:

var a = {x:10, y:100};

因此,我们在对类型进行转换的时候,要特别注意以下几点:

  • 这个对象是不是我们刚刚上文提过的类、实例、方法,是的话在其进入到Objective-C执行上下文的之前从JSWrapperObject中取出来。
  • 这个对象是不是特定类型的结构体,是的话我们将其转换成结构体,比如CGRect之类的,是的话需要特别转换
  • 是不是可以直接转换成特定类型的对象,比如Date <-> NSDate的转换。
  • 最后,将其可遍历的属性和对应的属性值,转换到NSDictionary之中。
  • 当然,别忘了,需要注意递归处理

Calling Convention

关于Calling Convention,本文就不再赘述,有兴趣的读者可以参考我和同事一起写的知乎专栏iOS调试进阶

简单来重新描述下就是:

一个函数的调用过程中,函数的参数既可以使用栈传递,也可以使用寄存器传递,参数压栈的顺序可以从左到右也可以从右到左,函数调用后参数从栈弹出这个工作可以由函数调用方完成,也可以由被调用方完成。如果函数的调用方和被调用方(函数本身)不遵循统一的约定,有这么多分歧这个函数调用就没法完成。这个双方必须遵守的统一约定就叫做调用惯例(Calling Convention),调用惯例规定了参数的传递的顺序和方式,以及栈的维护方式。

由于业界已经有知名大佬写的libffi,所以我们不需要重复发明轮子,直接使用即可。如果真的要了解具体原理,也可以参考我的文章,具体分析objc_msgSend的实现流程。

其他

为了偷懒,我直接用JavaScript实现了这些的效果。其实理论上,如果我完整的实现编译前端,构建抽象语法树分析执行上下文,将Objective-C的代码转换成JavaScript,那么就能实现动态执行Objective-C代码了。(当然本质上还是障眼法)

其实更快的方式,且不能保证完全正确的方式,就是调用一下JSPatchConvertor就好了,哈哈哈。