如何为macOS应用开发插件

本文章只用于学习和交流,转载请注明出处。

玩过越狱手机的应该都了解,iOS的越狱插件是可以为系统或者应用添加一些特色的功能的。越狱插件的开发,已经有完整的工具链,并且插件的加载运行Substrate框架已经为我们做好。

更改应用的功能实现有两种方法,一种是通过反编译工具更改汇编代码后生成新的可执行文件。另一种是写一个动态库,然后注入到可执行文件。第一种适合简单的逻辑修改,毕竟用汇编实现功能是需要很大的工作量的。

第二种要怎么注入呢?了解Mach-O 二进制文件的应该知道,Mach-O 二进制文件Load Commands中的 LC_LOAD_DYLIB 标头告诉 macOS在执行期间要加载哪些动态库 (dylib)。所以我们只需要在二进制文件中添加一条LC_LOAD_DYLIB就可以。而insert_dylib工具已经为我们实现了添加的功能。所以现在唯一需要考虑的就是插件如何开发。

废话不多说,现在就以mac版的迅雷为例开发一款属于我们自己的插件。首先下载最新版本的迅雷(版本5.0.2)。然后用Xcode新建一个macOS下的Framework,如下

新建完目录结构如下,如果有Tests target 不影响后续。

然后新建个.m文件(文件名随意),把以下代码复制粘贴进去

#import "libThunderPlugin.h"
#import "objc/runtime.h"

void lm_hookMethod(Class originalClass, SEL originalSelector, Class swizzledClass, SEL swizzledSelector) {
    Method originalMethod = class_getInstanceMethod(originalClass, originalSelector);
    Method swizzledMethod = class_getInstanceMethod(swizzledClass, swizzledSelector);
    if(originalMethod && swizzledMethod) {
        method_exchangeImplementations(originalMethod, swizzledMethod);
    }
}

@implementation NSObject (Thunder)
+ (void)hookThunder{
    lm_hookMethod(objc_getClass("XLUserInformation"), @selector(initWithUserInfo:), [self class], @selector(hook_initWithUserInfo:));
    lm_hookMethod(objc_getClass("XLUserInformation"), @selector(updateUserInfo:), [self class], @selector(hook_updateUserInfo:));
}
- (id)hook_initWithUserInfo:(id)arg1{
    return [self hook_initWithUserInfo:[self modify:arg1]];
}
- (void)hook_updateUserInfo:(id)arg1{
    [self hook_updateUserInfo:[self modify:arg1]];
}
- (NSDictionary *)modify:(NSDictionary *)user{
    NSDictionary *vipInfo = [[user objectForKey:@"vipList"] firstObject];
    NSMutableDictionary *mutableVipInfo = [vipInfo mutableCopy];
    [mutableVipInfo setValue:@"1" forKey:@"isVip"];
    [mutableVipInfo setValue:@"20990131" forKey:@"expireDate"];
    [mutableVipInfo setValue:@"4" forKey:@"vasType"];
    NSMutableDictionary *userInfo = [user mutableCopy];
    [userInfo setObject:@[mutableVipInfo] forKey:@"vipList"];
    return userInfo;
}
@end

static void __attribute__((constructor)) initialize(void) {
    [NSObject hookThunder];
}

再把以下代码复制粘贴到.h文件中

@interface XLUserInformation : NSObject
- (void)updateUserInfo:(id)arg1;
- (id)initWithUserInfo:(id)arg1;
@end

后续会说明代码的意思和怎么获取的。以上操作完成后编译运行,会生成一个framework,但是Xcode13把Products隐藏了,并不能很便捷的找到生成的动态库。

这时候我们需要按照以下步骤可以使Products文件显示出来。

1、找到项目工程文件,右键点击libThunderPlugin.xcodeproj -> 选择显示包内容

2、双击打开project.pbxproj文件,在文件中搜索productRefGroup关键字

3、将productRefGroup对应的值改成和mainGroup相同即可。

改完后Xcode会自动刷新,显示出Products目录。

然后右键生成的framework,点击 Show in Finder,然后把framework复制到迅雷的可执行文件同目录下,目录为 /Applications/Thunder.app/Contents/MacOS。

framework有了,下面就是注入到可执行文件。去Github上下载insert_dylib代码,编译得到insert_dylib文件。同样复制到迅雷可执行文件同目录下,再把Thunder改成Thunder_backup。

然后打开终端,进入到这个目录下面,执行如下代码

./insert_dylib --all-yes /Applications/Thunder.app/Contents/MacOS/libThunderPlugin.framework/libThunderPlugin Thunder_backup Thunder

出现以下内容就表示成功了

同时目录下会再生成个Thunder文件。

这时候再打开迅雷,登录账号,会发现已经成为SVIP了,并且有效期到2099年。

不过这仅仅是显示而已,并没有实际作用。其他功能感兴趣自己研究,这里仅作为例子。

下面开始讲代码,做iOS开发的应该可以看懂,上面的代码其实就是通过runtime替换原有方法实现,更改参数以实现显示的效果。但是这里的重点是怎么去找替换那个方法,更改哪些参数。

应用的方法和类信息存在于Mach-O 二进制文件中,我们是可以从二进制文件中提取这些信息的。下面介绍一款工具class-dump,它可以使我们快速的获取到应用的头文件信息。

同样和insert_dylib一样,我们需要下载源码,编译可执行文件。如果安装了MonkeyDev的就可以直接使用。打开终端,使用以下命令获取头文件

class-dump -H /Applications/Thunder.app/Contents/MacOS/Thunder -o ~/Desktop/Thunder.h

-H是获取头文件,-o 是指定头文件的目录,不指定就是当前文件夹。这里就不多做介绍。完成后找到Thunder.h目录,会发现有好多文件

这种情况要怎么找呢?一个个找无疑就是大海捞针。首先我们要学会关键字搜索,我们的目的是让用户状态显示为vip,那就尝试搜索这个关键字试试。

很明显,头文件少了很多。打开第一个文件看看,看名字就知道这个是用户信息的模型类。这个文件有各种用户信息相关的属性,并且提供了两个方法。

方法找到了,下一步就是验证这个方法是不是我们想要找的。导入头文件写代码

void lm_hookMethod(Class originalClass, SEL originalSelector, Class swizzledClass, SEL swizzledSelector) {
    Method originalMethod = class_getInstanceMethod(originalClass, originalSelector);
    Method swizzledMethod = class_getInstanceMethod(swizzledClass, swizzledSelector);
    if(originalMethod && swizzledMethod) {
        method_exchangeImplementations(originalMethod, swizzledMethod);
    }
}

@implementation NSObject (Thunder)
+ (void)hookThunder{
    lm_hookMethod(objc_getClass("XLUserInformation"), @selector(initWithUserInfo:), [self class], @selector(hook_initWithUserInfo:));
    lm_hookMethod(objc_getClass("XLUserInformation"), @selector(updateUserInfo:), [self class], @selector(hook_updateUserInfo:));
}
- (id)hook_initWithUserInfo:(id)arg1{
    return [self hook_initWithUserInfo:arg1];
}
- (void)hook_updateUserInfo:(id)arg1{
    [self hook_updateUserInfo:arg1];
}
@end

上面代码是通过runtime实现了方法的hook,这时又有新问题出现了,我们怎么知道方法参数类型,要改哪些值才能实现我们的目的?这个时候就想能调试就好了。做过越狱开发的应该都了解,我们通过给debugserver添加task_for_pid权限就可以调试第三方应用了。macOS上没那么复杂,只需要在编译framework的时候选择启动我们需要调试的应用即可。

在Edit Scheme里面,Executable里面选择我们需要调试的应用,如图

点击Other,然后选择迅雷即可。完成后会变成这样

这时候再点击运行,就会发现可以调试了。如果没有之前insert_dylib的操作,会有以下错误

Failure Reason: attach failed (Not allowed to attach to process.  Look in the console messages (Console.app), near the debugserver entries, when the attach failed.  The subsystem that denied the attach permission will likely have logged an informative message about why it was denied.)

可以看出这里是权限的问题,运行的时候验证了签名,insert_dylib处理过的二进制文件移除了LC_CODE_SIGNATURE,所以可以正常调试。这里只需要更改签名添加权限即可。先把以下内容保存到文件中,文件名随意,我用的是entitlements.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>com.apple.security.cs.allow-jit</key>
      <true/>
    <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
      <true/>
    <key>com.apple.security.cs.allow-dyld-environment-variables</key>
      <true/>
    <key>com.apple.security.cs.disable-library-validation</key>
      <true/>
    <key>com.apple.security.get-task-allow</key>
      <true/>
  </dict>
</plist>

然后通过以下命令替换二进制文件的签名,开发者证书名选择自己的证书即可。

codesign --entitlements entitlements.xml -fs <开发者证书名> /Applications/Thunder.app

这个就表示替换成功了。然后再运行发现还是会报错,但是现在可以正常启动应用了。

这时候在我们自己方法里面打断点,发现还是不会断住,这是因为应用还没有加载我们的动态库。只需要在应用启动之前把我们的动态库插入到应用即可,这时候又需要用到insert_dylib了。在项目根目录创建个Other文件夹,然后把insert_dylib放进去。

然后在工程Build Phases中创建个脚本

创建完成后,把以下内容复制进去

app_name="Thunder"
framework_name="libThunderPlugin"
app_bundle_path="/Applications/${app_name}.app/Contents/MacOS"
app_executable_path="${app_bundle_path}/${app_name}"
app_executable_backup_path="${app_executable_path}_backup"
framework_path="${app_bundle_path}/${framework_name}.framework"
# 备份原始可执行文件
if [ ! -f "$app_executable_backup_path" ]
then
cp "$app_executable_path" "$app_executable_backup_path"
fi

rm -rf "./Other/Products/Debug/${framework_name}.framework"
cp -r "${BUILT_PRODUCTS_DIR}/${framework_name}.framework" "./Other/Products/Debug/${framework_name}.framework"
cp -r "${BUILT_PRODUCTS_DIR}/${framework_name}.framework" ${app_bundle_path}
./Other/insert_dylib --all-yes "${framework_path}/${framework_name}" "$app_executable_backup_path" "$app_executable_path"

这里面的内容其实就是帮我们完成了上面我们手动插入framework的操作。上述操作都完成后断点还是没有生效的,是因为还没有调用方法的替换,一般的话是在类 + (void)load方法里面实现方法的替换,但是对于framework来说,还会有一些C、C++和Swift的方法替换,这种情况是没有load方法的。了解dyld加载流程的应该知道,dyld会调用每个framework的initialize方法去初始化。然后我们写入以下代码

static void __attribute__((constructor)) initialize(void) {
    [NSObject hookThunder];
}

这时候再运行,发现是可以正常断住的,并且可以看到arg1是NSDictionary类型。

具体更改哪些数据可以达到我们想要的效果就需要自己一个个尝试了,这里就不多说了。

到这里就会有人问,如果通过搜索找不到我们需要的方法怎么办?不过确实,做逆向不是每次都那么好运气的。如果这样的话就需要通过UI找数据了。Xcode有个Debug View Hierarchy的功能,如下

感兴趣自己研究吧,这里就不多说了。