之前听说滴滴的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
2
3
4
5
6
7JSClassDefinition 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
协议了,这个协议在运行时会按照如下逻辑进行处理,将方法和属性进行诸如注入:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45检查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];
}1
2
3
4
5
6注入方法和属性
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
判断获取到的数据其类型是不是我们要的。
// 遍历zonefor (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
就好了,哈哈哈。
转载:http://satanwoo.github.io/2018/04/01/jsengine/
发表评论