缘起
我们公司 iOS 小组一直在用一款内部 IM 工具,它最初是以 Mac Catalyst 方式从 iOS App 转换而来的。两年前一位同事编译了一份 .dmg,丢在共享文件夹里,大家就这么用了两年。
直到最近,同事反馈在 ARM 架构的 Mac 上显示问题比较多。除此之外,这个 Catalyst 版本本身还有一些”历史遗留问题”:没有工作状态展示、没有日程入口、富文本消息里的图片点不开、视频播放器不能暂停也不能拖进度条。
既然只是小组内部使用,源码工程非常庞大且编译报错,其实逆向就是一个非常适合的办法。
搞清楚是怎么跑起来的:Catalyst 双世界
Mac Catalyst 是一个很微妙的运行时。它本质上是把 UIKit App 跑在 macOS 上,编译目标为 x86_64-apple-ios-macabi。这意味着:
- UI 层面用的是 UIKit(
UIView、UIViewController),而不是 AppKit
- 但它又运行在真正的 macOS 进程空间里,理论上可以 dlopen 加载纯 macOS 的动态库
这个”双世界”特征正是我们逆向方案的核心支点:在同一个进程里,同时运行 UIKit 注入代码和原生 AppKit 代码。
整体思路:双 dylib 协同注入
我们设计了两个协同工作的动态库:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| ┌──────────────────────────────────────┐ │ 跨声.app (Catalyst) │ │ │ │ ┌──────────────┐ ┌──────────────┐ │ │ │ KKInject │ │ KKMacHelper │ │ │ │ .dylib │ │ .dylib │ │ │ │ │ │ │ │ │ │ UIKit 世界 │◄─┤ AppKit 世界 │ │ │ │ Hook + 拦截 │ │ 原生窗口 │ │ │ │ │ │ │ │ │ └──────┬───────┘ └──────┬───────┘ │ │ │ 事件总线 │ │ │ └─── NSNotification ◄───────┘ │ │ Center │ └──────────────────────────────────────┘
|
- KKInject.dylib:编译目标为
x86_64-apple-ios-macabi(与 Catalyst 一致),负责 Hook UIKit 层的类,拦截用户交互和网络请求
- KKMacHelper.dylib:编译目标为
x86_64-apple-macos(纯 Mac),负责创建原生 NSWindow、处理日程展示等需要 AppKit 的功能
两者的通信通过 macOS 进程内的 NSNotificationCenter 完成——因为它们被加载到同一个进程空间,通知可以无缝广播。
第一步:让 dylib 注入进去
要让你的代码跑在别人 App 的进程里,核心步骤是把 dylib 塞进 App 的加载链。我们选用的是 insert_dylib 这个开源工具,它直接修改 Mach-O 的 Load Commands。
简化后的注入脚本逻辑如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| cp KKInject.dylib "$APP_PATH/Contents/Frameworks/" cp KKMacHelper.dylib "$APP_PATH/Contents/Frameworks/"
insert_dylib @rpath/KKInject.dylib "$APP_PATH/Contents/MacOS/跨声" --all-yes
plutil -replace com.apple.security.cs.disable-library-validation -bool YES \ "$APP_PATH/Contents/Resources/entitlements.plist"
codesign --force --deep -s - "$APP_PATH"
|
KKInject.dylib 是注入入口,它在 constructor 中负责使用 dlopen 加载 KKMacHelper.dylib:
1 2 3 4 5 6 7 8 9 10 11 12
| __attribute__((constructor)) static void kk_init(void) { @autoreleasepool { KKHookHelper *hookHelper = [KKHookHelper sharedInstance]; [hookHelper startAllHooks]; NSString *helperPath = [[NSBundle mainBundle].bundlePath stringByAppendingPathComponent:@"Contents/Frameworks/KKMacHelper.dylib"]; dlopen([helperPath UTF8String], RTLD_NOW); } }
|
把这一切串起来,一个 start.sh 就能实现一键构建、注入、启动。
核心武器:Method Swizzling 框架
逆向开发中,Method Swizzling 是最基础也最可靠的手段。我们封装了一个 KKHookHelper,它提供了一套安全的方法替换 API。这个库不涉及任何业务逻辑,下面是完整实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| @interface KKHookHelper : NSObject
+ (instancetype)sharedInstance;
- (void)hookInstanceMethod:(SEL)originalSelector fromClass:(Class)originalClass withNewClass:(Class)newClass andNewMethod:(SEL)newSelector;
- (void)hookClassMethod:(SEL)originalSelector fromClass:(Class)originalClass withNewClass:(Class)newClass andNewMethod:(SEL)newSelector;
- (void)startAllHooks;
@end
|
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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86
| #import "KKHookHelper.h" #import "KKLog.h" #import <objc/runtime.h>
@implementation KKHookHelper
+ (instancetype)sharedInstance { static KKHookHelper *instance = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ instance = [[KKHookHelper alloc] init]; }); return instance; }
- (void)hookInstanceMethod:(SEL)originalSelector fromClass:(Class)originalClass withNewClass:(Class)newClass andNewMethod:(SEL)newSelector { if (!originalClass || !newClass) { KKLog(@"Hook failed: nil class - original: %@, new: %@", originalClass, newClass); return; } Method originalMethod = class_getInstanceMethod(originalClass, originalSelector); Method newMethod = class_getInstanceMethod(newClass, newSelector); if (!originalMethod) { KKLog(@"Warning: original method '%@' not found in class '%@'", NSStringFromSelector(originalSelector), NSStringFromClass(originalClass)); return; } IMP originalIMP = method_getImplementation(originalMethod); IMP newIMP = method_getImplementation(newMethod); const char *typeEncoding = method_getTypeEncoding(originalMethod); BOOL didAdd = class_addMethod(originalClass, newSelector, newIMP, typeEncoding); if (didAdd) { Method addedMethod = class_getInstanceMethod(originalClass, newSelector); method_exchangeImplementations(originalMethod, addedMethod); KKLog(@"Hook success (exchange): -[%@ %@]", NSStringFromClass(originalClass), NSStringFromSelector(originalSelector)); } else { class_replaceMethod(originalClass, originalSelector, newIMP, typeEncoding); KKLog(@"Hook success (replace): -[%@ %@]", NSStringFromClass(originalClass), NSStringFromSelector(originalSelector)); } }
- (void)hookClassMethod:(SEL)originalSelector fromClass:(Class)originalClass withNewClass:(Class)newClass andNewMethod:(SEL)newSelector { Class originalMetaClass = object_getClass(originalClass); Class newMetaClass = object_getClass(newClass); [self hookInstanceMethod:originalSelector fromClass:originalMetaClass withNewClass:newMetaClass andNewMethod:newSelector]; }
- (void)startAllHooks { [[NSNotificationCenter defaultCenter] postNotificationName:@"KKStartAllHooks" object:nil]; }
@end
|
这套框架的精髓在于三点:
- 安全检查:Hook 前验证 original class、original method 是否存在,防止因版本差异导致崩溃
- 双路径策略:
class_addMethod + method_exchangeImplementations 是第一选择,class_replaceMethod 是 fallback,兼容各种场景
- 统一日志:每次 Hook 都有日志记录,出问题时可以快速定位是哪个 Hook 失败了
打通两个世界:进程内事件总线
KKInject(UIKit 世界)和 KKMacHelper(AppKit 世界)需要在同一个进程中通信。最自然的方式就是 NSNotificationCenter——不需要 Mach Port、不需要 XPC Service,简单直接。
我们将相同的源文件分别编译进两个 dylib:
1 2 3 4 5 6 7
| @interface KKEventBus : NSObject
+ (void)postEvent:(NSString *)eventType payload:(NSDictionary *)payload; + (id)observeEvent:(NSString *)eventType handler:(void(^)(NSDictionary *payload))handler;
@end
|
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
| #import "KKEventBus.h"
static NSString * const kKKEventNotification = @"com.kk.internal.eventbus";
@implementation KKEventBus
+ (void)postEvent:(NSString *)eventType payload:(NSDictionary *)payload { NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:payload ?: @{}]; userInfo[@"eventType"] = eventType; [[NSNotificationCenter defaultCenter] postNotificationName:kKKEventNotification object:nil userInfo:userInfo]; }
+ (id)observeEvent:(NSString *)eventType handler:(void(^)(NSDictionary *payload))handler { return [[NSNotificationCenter defaultCenter] addObserverForName:kKKEventNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *note) { NSString *type = note.userInfo[@"eventType"]; if ([type isEqualToString:eventType]) { handler(note.userInfo); } }]; }
@end
|
这种方式的关键在于:两个 dylib 编译的是同一份源码,注册到的是同一个 NSNotificationCenter 实例(因为它们在同一进程空间)。KKInject 侧 post 一个 schedule_open 事件,KKMacHelper 侧就能收到,然后创建原生 NSWindow。
逐个击破:修复六大问题
问题一:ARM 架构崩溃
现象:M 芯片 Mac 上启动即闪退。
排查:查看崩溃日志,发现是 x86-only 的 dylib 在 ARM 架构下无法加载。Catalyst App 的编译目标从 x86_64-apple-ios 改为 arm64-apple-ios + x86_64-apple-ios 双架构即可。
解决:在 build.sh 中修改 -arch x86_64 为 -arch x86_64 -arch arm64,编译 Universal Binary 的 dylib。同时,insert_dylib 工具也需要替换为支持 ARM64 的版本。
1 2 3 4 5 6 7 8
| clang -arch x86_64 -target x86_64-apple-ios14.0-macabi ...
clang -arch x86_64 -arch arm64 \ -target x86_64-apple-ios14.0-macabi \ -Xlinker -target -Xlinker arm64-apple-ios14.0-macabi \ ...
|
问题二:工作状态不显示
现象:同事的头像旁看不到”在线/忙碌/离开”等状态标签。
分析:通过 Class Dump 分析发现,工作状态由一个 [工作状态视图] 负责渲染。它在 layoutSubviews 时使用系统默认字号,在 Mac 大屏幕上显得不协调。
Hook 方案(伪代码):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| Class statusViewCls = NSClassFromString(@"内部工作状态视图类");
[hookHelper hookInstanceMethod:@selector(layoutSubviews) fromClass:statusViewCls withNewClass:[self class] andNewMethod:@selector(kk_status_layoutSubviews)];
- (void)kk_status_layoutSubviews { [self kk_status_layoutSubviews]; UILabel *statusLabel = [self valueForKey:@"内部状态标签属性"]; statusLabel.font = [UIFont systemFontOfSize:14]; }
|
问题三:缺少日程入口
现象:原始 Catalyst 版本侧边栏只有”消息””文档”等入口,没有日程。
方案:使用 三层 Hook 组合拳——Hook 侧边栏添加按钮、Hook 首页菜单添加快捷入口、Hook 事件路由转发打开指令。
Hook 侧边栏添加日程按钮(伪代码):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| Class sidebarCls = NSClassFromString(@"内部侧边栏类"); [hookHelper hookInstanceMethod:@selector(initWithFrame:) fromClass:sidebarCls withNewClass:[self class] andNewMethod:@selector(kk_sidebar_initWithFrame:)];
- (instancetype)kk_sidebar_initWithFrame:(CGRect)frame { id self_ = [self kk_sidebar_initWithFrame:frame]; UIButton *scheduleBtn = [UIButton buttonWithType:UIButtonTypeCustom]; [scheduleBtn setTitle:@"日程" forState:UIControlStateNormal]; [scheduleBtn setImage:[UIImage imageNamed:@"calendar_icon"] forState:UIControlStateNormal]; [scheduleBtn addTarget:self_ action:@selector(kk_onScheduleTapped) forControlEvents:UIControlEventTouchUpInside]; [self_ addSubview:scheduleBtn]; return self_; }
|
日程窗口的打开:KKInject 侧发出事件,KKMacHelper 侧用 AppKit 创建原生窗口:
1 2 3 4 5 6 7 8 9 10 11
| [KKEventBus postEvent:@"schedule_open" payload:@{@"date": dateStr}];
+ (void)load { [KKEventBus observeEvent:@"schedule_open" handler:^(NSDictionary *payload) { dispatch_async(dispatch_get_main_queue(), ^{ [[KKScheduleWindowController shared] showWindow]; }); }]; }
|
问题四:富文本消息中图片无法预览
现象:群聊中发送的图文混排消息,点击图片没有反应。
分析:原有逻辑中,图片点击事件被转发到一个仅实现了一半的预览器,图片 URL 拿到了但没有渲染出来。需要 Hook 富文本内容视图的消息绑定方法和图片容器的点击方法。
方案核心(伪代码):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| Class richTextCls = NSClassFromString(@"内部富文本内容视图"); [hookHelper hookInstanceMethod:@selector(内部UI绑定方法:) fromClass:richTextCls withNewClass:[self class] andNewMethod:@selector(kk_richtext_uiBind:)];
- (void)kk_richtext_uiBind:(id)message { [self kk_richtext_uiBind:message]; NSArray *images = ; if (images.count > 0) { [KKImagePreviewer showWithImageURLs:images currentIndex:0]; } }
|
问题五:图片预览器实现
这是纯业务无关的 UI 代码,可以详细展开。我们基于 WKWebView + Viewer.js 实现了一个全功能图片浏览器:
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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122
| @interface KKImagePreviewer : UIViewController
+ (void)showWithImageURLs:(NSArray<NSString *> *)urls currentIndex:(NSInteger)index;
@end
#import "KKImagePreviewer.h" #import <WebKit/WebKit.h>
@interface KKImagePreviewer () <WKNavigationDelegate> @property (nonatomic, strong) WKWebView *webView; @property (nonatomic, strong) NSArray<NSString *> *imageURLs; @property (nonatomic, assign) NSInteger currentIndex; @end
@implementation KKImagePreviewer
+ (void)showWithImageURLs:(NSArray<NSString *> *)urls currentIndex:(NSInteger)index { KKImagePreviewer *previewer = [[KKImagePreviewer alloc] init]; previewer.imageURLs = urls; previewer.currentIndex = index; previewer.modalPresentationStyle = UIModalPresentationFullScreen; UIViewController *rootVC = ; [rootVC presentViewController:previewer animated:YES completion:nil]; }
- (void)viewDidLoad { [super viewDidLoad]; self.view.backgroundColor = [UIColor blackColor]; UIButton *closeBtn = [UIButton buttonWithType:UIButtonTypeSystem]; closeBtn.frame = CGRectMake(20, 40, 44, 44); [closeBtn setTitle:@"✕" forState:UIControlStateNormal]; closeBtn.titleLabel.font = [UIFont systemFontOfSize:24]; [closeBtn addTarget:self action:@selector(dismiss) forControlEvents:UIControlEventTouchUpInside]; [self.view addSubview:closeBtn]; UIButton *downloadBtn = [UIButton buttonWithType:UIButtonTypeSystem]; downloadBtn.frame = CGRectMake(self.view.bounds.size.width - 64, 40, 44, 44); [downloadBtn setTitle:@"↓" forState:UIControlStateNormal]; downloadBtn.titleLabel.font = [UIFont systemFontOfSize:24]; [downloadBtn addTarget:self action:@selector(downloadCurrentImage) forControlEvents:UIControlEventTouchUpInside]; [self.view addSubview:downloadBtn]; NSString *html = [self buildViewerHTML]; self.webView = [[WKWebView alloc] initWithFrame:self.view.bounds]; self.webView.backgroundColor = [UIColor blackColor]; self.webView.navigationDelegate = self; [self.webView loadHTMLString:html baseURL:nil]; [self.view insertSubview:self.webView atIndex:0]; }
- (NSString *)buildViewerHTML { NSMutableString *imagesHTML = [NSMutableString string]; for (NSString *url in self.imageURLs) { [imagesHTML appendFormat:@"<div><img src=\"%@\" style=\"display:none\"></div>\n", url]; } NSString *viewerJS = [self loadResourceFile:@"viewer.min.js"]; NSString *viewerCSS = [self loadResourceFile:@"viewer.min.css"]; return [NSString stringWithFormat:@"<!DOCTYPE html>" "<html><head>" "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1,maximum-scale=1\">" "<style>%@</style>" "<style>body{margin:0;background:#000}" ".viewer-toolbar>li{font-size:16px}</style>" "</head><body>" "<div id=\"gallery\">%@</div>" "<script>%@</script>" "<script>new Viewer(document.getElementById('gallery'),{" "toolbar:{prev:1,next:1,zoomIn:1,zoomOut:1," "oneToOne:1,reset:1,download:function(){" "window.webkit.messageHandlers.download.postMessage(" "this.image.src)}}," "keyboard:1,initialViewIndex:%ld})</script>" "</body></html>", viewerCSS, imagesHTML, viewerJS, (long)self.currentIndex]; }
- (void)dismiss { [self dismissViewControllerAnimated:YES completion:nil]; }
- (void)downloadCurrentImage { [self.webView evaluateJavaScript: @"document.querySelector('.viewer-canvas img').src" completionHandler:^(NSString *url, NSError *error) { if (url) { NSData *data = [NSData dataWithContentsOfURL:[NSURL URLWithString:url]]; NSString *fileName = [url lastPathComponent]; NSString *downloadPath = [@"~/Downloads" stringByAppendingPathComponent:fileName]; [data writeToFile:[downloadPath stringByExpandingTildeInPath] atomically:YES]; } }]; }
- (NSString *)loadResourceFile:(NSString *)filename { NSString *path = [[NSBundle mainBundle] pathForResource:[filename stringByDeletingPathExtension] ofType:[filename pathExtension]]; return [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil]; }
@end
|
问题六:视频播放器不能暂停和快进
现象:点击视频消息后,视频开始播放但无法暂停,也无法拖进度条。
方案:同样使用 WKWebView,搭载 Plyr 播放器。Plyr 内置了完整的控制条:播放/暂停按钮、进度条拖拽、快进快退、音量控制等。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| - (NSString *)buildPlayerHTML { NSString *plyrJS = [self loadResourceFile:@"plyr.js"]; NSString *plyrCSS = [self loadResourceFile:@"plyr.css"]; return [NSString stringWithFormat:@"<!DOCTYPE html>" "<html><head>" "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">" "<link rel=\"stylesheet\" href=\"%@\">" "<style>body{margin:0;display:flex;align-items:center;" "justify-content:center;background:#000;height:100vh}" ".plyr{width:100%%;max-width:900px}</style>" "</head><body>" "<video id=\"player\" controls playsinline>" "<source src=\"%@\"></video>" "<script src=\"%@\"></script>" "<script>new Plyr('#player',{" "controls:['play-large','play','progress','current-time'," "'duration','mute','volume','pip','fullscreen']," "keyboard:{focused:true,global:true}})</script>" "</body></html>", plyrCSS, self.videoURL, plyrJS]; }
|
这样视频播放就获得了完整的控制能力:暂停、拖拽进度条、快进快退 5 秒、画中画、全屏。
编译、注入、打包一条龙
最终的构建流程由 start.sh 统一管理:
1 2 3 4 5 6 7 8 9 10 11 12
| #!/bin/bash
cd KKMacHelper/Scripts sh build.sh
cd ../../KKInject/Scripts sh build.sh
sh inject.sh 首次启动
|
build.sh 的核心编译命令:
1 2 3 4 5 6 7 8
| clang -dynamiclib \ -arch x86_64 -arch arm64 \ -target x86_64-apple-ios14.0-macabi \ -isysroot $(xcrun --sdk macosx --show-sdk-path) \ -F $(xcrun --sdk macosx --show-sdk-path)/System/Library/Frameworks \ -framework Foundation -framework UIKit -framework WebKit \ -o KKInject.dylib \ Sources/*.m Sources/Modules/*.m
|
写在最后
整个过程下来,最深的感受是:Mac Catalyst 的”双世界”特性是逆向开发者的福音。同一个进程里可以运行 UIKit Hook 代码和原生 AppKit 代码,这让我们既能拦截 iOS 层的交互,又能创建原生 Mac 窗口。
几个踩坑经验:
+load vs __attribute__((constructor)):+load 在 constructor 之前执行,如果 KKMacHelper 的 +load 依赖 KKInject 的初始化,就要注意调用顺序
- NSNotificationCenter 的跨 dylib 陷阱:如果用
object: 参数指定 nil 以外的对象,而那个对象的类在另一个 dylib 中只有一份符号表,通知可能收不到。建议 object: 统一传 nil,用 userInfo 中的字段做路由
- Viewer.js 在 WKWebView 中的本地文件加载:WKWebView 默认不允许加载本地 JS/CSS 文件。需要在 HTML 中内联
<script> 和 <style>,或者将资源文件复制到 App 的 Resources 目录并用 file:// 协议
- Catalyst 的 sandbox 限制:注入的 dylib 继承了 App 的 sandbox,写文件只能写到
/Downloads、/Desktop 等用户批准过的目录
逆向不是目的,解决问题才是。当你面对一个没有源码、但有明确痛点的内部工具时,逆向 + Hook 注入可能是最务实的解法。