之前听说滴滴的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
,没有的话就进行构建,构建过程如下:
1 | JSClassDefinition definition; |
- 没啥特别的,就是OC对象创建对应的JS对象,类型对类型。
- OC类型的继承关系在JS里面通过设置Constructor和Prototype进行构建,其实就是简单的JavaScript原型链继承。
JSExport协议 & JSExportAs
JSExport
协议本质上只是个Protocol
标记,用于让JavaScriptCore加载那些打上这个特殊标记的类,用于特定方式的注册及初始化。
上文我们提过,默认情况下,JavaScriptCore会对象创建一个默认的Wrapper Object
,但是这个对象除了简单继承关系外,也就一个按照特殊格式命令的Constructor
而已:
[NSString stringWithFormat:@"%sConstructor", className]
那如果我们需要将OC环境中的方法注入到JS环境中,就需要用到JSExport
协议了,这个协议在运行时会按照如下逻辑进行处理,将方法和属性进行诸如注入:
1 | 检查init方法簇的方法,并根据这么合法提供合理的 |
1 | 注入方法和属性 |
而至于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
就好了,哈哈哈。