运行时与私有 API
方法交换、私有类名混淆、XOR 字节串与精确 ABI 调用。
范围:Telegram-iOS(f1dd7a2dbd02cbbf513e75d5695d8d36d1cf5838)的 UIKit 运行时 hack、方法交换与私有 API 混淆。
1. 借安全文本输入层给任意图层打上截图排除标记
做了什么:代码找到 UITextField 的内部 TextLayoutCanvasView。代码临时用 KVC 把目标 CALayer 替换成该视图的 layer。代码切换 secureTextEntry 后恢复原图层。秘密聊天的历史节点会调用此函数。
为什么这样做:公开 UIKit 没有“排除指定图层截图”的 API。安全文本输入由系统处理截图保护。该做法把系统标记转移给任意显示图层。
关键代码摘录
dispatch_once(&onceToken, ^{
textField = [[UITextField alloc] init];
for (UIView *subview in textField.subviews) {
if ([NSStringFromClass([subview class]) containsString:@"TextLayoutCanvasView"]) {
secureView = subview;
break;
}
}
});
if (secureView == nil) {
return;
}
CALayer *previousLayer = secureView.layer;
[secureView setValue:layer forKey:@"layer"];
if (disableScreenshots) {
textField.secureTextEntry = false;
textField.secureTextEntry = true;
} else {
textField.secureTextEntry = true;
textField.secureTextEntry = false;
}
[secureView setValue:previousLayer forKey:@"layer"];
[layer setAssociatedObject:@(disableScreenshots) forKey:layerDisableScreenshotsKey
associationPolicy:NSObjectAssociationPolicyRetain];
}代价与风险:它依赖内部子视图类名和可 KVC 写入的 layer。系统层级或实现改变会失效。它使用未公开行为,存在审核和兼容性风险。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:
dispatch_once缓存内部子视图,KVC 交换layer,切换两次secureTextEntry,恢复原图层。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
2. 用 XOR 字节串隐藏私有拼写控制器,并用精确 ABI 调用它
做了什么:代码将私有类和 selector 的 UTF-8 字节逐字节 XOR 0x5A。代码只在运行时解码并查找。代码把带 BOOL 的语法检查调用转换为准确的 C 函数类型。
混淆还原:我按 byte ^ 0x5A 解码源码数组。结果是 UITextCheckingController、initWithClient:、preheatTextChecker、invalidate、checkSpellingForWordInRange: 和 checkGrammarForSentenceInRannge:onPause:。最后一个真实字符串含 Rannge 的双 n。属性键还原为 NSTextAlternativesDisplayStyle、NSTextAlternatives、alternativeStrings 和 primaryString。
为什么这样做:perform 不能安全传递尾部标量 BOOL。私有符号明文也更容易进入产物。运行时可用性检查让类不存在时直接关闭检查。
关键代码摘录
/// Runtime-assembled private symbol strings (XOR 0x5A) so no plaintext private
/// class/selector appears in the binary.
enum ObfuscatedStrings {
static let key: UInt8 = 0x5A
static func decode(_ bytes: [UInt8]) -> String {
String(decoding: bytes.map { $0 ^ key }, as: UTF8.self)
}
static let classNameBytes: [UInt8] = [15, 19, 14, 63, 34, 46, 25, 50, 63, 57, 49, 51, 52, 61, 25, 53, 52, 46, 40, 53, 54, 54, 63, 40]
static let selInitWithClient: [UInt8] = [51, 52, 51, 46, 13, 51, 46, 50, 25, 54, 51, 63, 52, 46, 96]
static let selCheckGrammar: [UInt8] = [57, 50, 63, 57, 49, 29, 40, 59, 55, 55, 59, 40, 28, 53, 40, 9, 63, 52, 46, 63, 52, 57, 63, 19, 52, 8, 59, 52, 52, 61, 63, 96, 53, 52, 10, 59, 47, 41, 63, 96]
}
// ...
let sel = NSSelectorFromString(ObfuscatedStrings.decode(ObfuscatedStrings.selCheckGrammar))
guard controller.responds(to: sel) else { return }
typealias GrammarFn = @convention(c) (AnyObject, Selector, AnyObject, Bool) -> Void
let fn = unsafeBitCast(controller.method(for: sel), to: GrammarFn.self)
fn(controller, sel, textRange, true)代价与风险:私有 UITextCheckingController 存在审核风险。selector、回调协议或 ABI 改变会导致功能关闭或崩溃。源码明确没有公开 API 回退。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:
NSSelectorFromString与method(for:)提取 IMP,unsafeBitCast为准确签名后调用。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
3. 伪造 Info.plist 结果,让 App-Bound 域名可以动态增删
做了什么:代码交换 NSBundle.objectForInfoDictionaryKey:。当 WebKit 查询 WKAppBoundDomains 时,代码返回带引用计数的运行时域名列表。代码再经 dlsym 调用私有重载函数刷新指定数据存储。
混淆还原:@"WK" + @"WebsiteDataStoreReinitializeAppBoundDomains" 还原为 WKWebsiteDataStoreReinitializeAppBoundDomains。这是源码构造后传给 dlsym 的符号名。
为什么这样做:WKAppBoundDomains 通常在构建时写入 Info.plist。浏览器代码设置 limitsNavigationsToAppBoundDomains 后才登记当前主机。普通配置无法按网页生命周期动态改变该列表。
关键代码摘录
+ (void)forceRefreshTrustedDomains:(WKWebsiteDataStore * _Nonnull)websiteDataStore {
static void (*reinitializeFunction)(CFTypeRef dataStoreRef) = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
NSString *nameString = [NSString stringWithFormat:@"WK%@", @"WebsiteDataStoreReinitializeAppBoundDomains"];
reinitializeFunction = dlsym(RTLD_DEFAULT, [nameString UTF8String]);
});
if (reinitializeFunction) {
reinitializeFunction((__bridge CFTypeRef)(websiteDataStore));
}
}
// ...
- (id)_65087dc8_objectForInfoDictionaryKey:(NSString *)key {
if ([key isEqualToString:@"WKAppBoundDomains"]) {
NSArray *result = [WebHelpers threadSafeTrustedDomains];
if (result.count != 0) {
return result;
}
}
return [self _65087dc8_objectForInfoDictionaryKey:key];
}代价与风险:它篡改全局 bundle 查询,并调用私有 C 符号。WebKit 改名、改参数或移除符号时会失效。存在审核风险。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:
dlsym解析WKWebsiteDataStoreReinitializeAppBoundDomains,并包裹原方法。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
4. 从私有键盘场景代理取得键盘窗口,并避开新版陷阱
做了什么:代码为未声明接口声明最小协议。代码通过 UIWindowScene.keyboardSceneDelegate.keyboardWindow 取得内部键盘窗口。每一步都先检查 selector 和结果类型。
为什么这样做:代码注释说明,iOS 16 后内部键盘窗口不在公开的 UIWindowScene.windows 和 UIApplication.windows。注释还说明旧的 UIRemoteKeyboardWindow remoteKeyboardWindowForScreen:create: 在链接 iOS 27 SDK 后会触发 brk #0。该路径刻意避开该入口。
关键代码摘录
@protocol UIKeyboardSceneDelegateProtocol
- (UIWindow * _Nullable)keyboardWindow;
@end
@protocol UIWindowSceneKeyboardProtocol
- (id _Nullable)keyboardSceneDelegate;
@end
// ...
- (UIWindow * _Nullable)internalGetKeyboardForScene:(UIWindowScene * _Nullable)scene {
if (scene == nil) {
return nil;
}
if (![scene respondsToSelector:@selector(keyboardSceneDelegate)]) {
return nil;
}
id keyboardSceneDelegate = [(id<UIWindowSceneKeyboardProtocol>)scene keyboardSceneDelegate];
if (![keyboardSceneDelegate respondsToSelector:@selector(keyboardWindow)]) {
return nil;
}
UIWindow *result = [(id<UIKeyboardSceneDelegateProtocol>)keyboardSceneDelegate keyboardWindow];
if (![result isKindOfClass:[UIWindow class]]) {
return nil;
}
return result;
}代价与风险:keyboardSceneDelegate 和 keyboardWindow 都是私有接口。进程外键盘时会返回 nil。系统内部场景模型改变时会失效,并存在审核风险。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
5. 用私有 cornerRadii 获得可动画的四角半径,避免离屏遮罩
做了什么:代码探测私有 setter setCornerRadii:。代码按 QuartzCore 类型编码把四个 CGSize 装入 NSValue。代码用 KVC 读写私有 cornerRadii 属性。
为什么这样做:一个 cornerRadius 无法表达四个不同半径。代码注释指出,遮罩会造成离屏渲染,并且不会随图层自然动画。私有属性是可动画的图层属性。
关键代码摘录
public extension CALayer {
static let cornerRadiiSupported: Bool = {
return CALayer.instancesRespond(to: NSSelectorFromString("setCornerRadii:"))
}()
var cornerRadii: CornerRadii? {
guard CALayer.cornerRadiiSupported,
let value = self.value(forKey: cornerRadiiKey) as? NSValue else {
return nil
}
return CornerRadii(boxedValue: value)
}
func setCornerRadii(_ radii: CornerRadii) {
guard CALayer.cornerRadiiSupported else {
return
}
self.setValue(radii.boxedValue, forKey: cornerRadiiKey)
}
}代价与风险:它依赖 CACornerRadii 的类型布局和私有 KVC 键。虽然先探测 selector,系统仍可改动值布局。该接口有审核风险。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:构造
NSValue封箱CACornerRadii,instancesRespond探测后调用。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
6. 交换继承方法前先落地到目标类,避免污染父类
做了什么:代码先尝试向目标类添加原 selector。添加成功说明原实现继承自父类。代码此时只在目标类安装交换后的入口,并把父类 IMP 放到替代 selector。只有原方法本来就在目标类时才直接交换。
为什么这样做:对继承来的 Method 直接 method_exchangeImplementations 会修改父类实现。这个常见细节会把一个子类 hook 意外扩散到其他子类。
关键代码摘录
+ (void)swizzleInstanceMethodOfClass:(Class)targetClass
currentSelector:(SEL)currentSelector
newSelector:(SEL)newSelector {
Method origMethod = class_getInstanceMethod(targetClass, currentSelector);
Method newMethod = class_getInstanceMethod(targetClass, newSelector);
if ((origMethod != nil) && (newMethod != nil)) {
if (class_addMethod(targetClass, currentSelector,
method_getImplementation(newMethod), method_getTypeEncoding(newMethod))) {
class_replaceMethod(targetClass, newSelector,
method_getImplementation(origMethod), method_getTypeEncoding(origMethod));
} else {
method_exchangeImplementations(origMethod, newMethod);
}
}
}代价与风险:交换依赖方法签名兼容。多个模块 hook 同一 selector 时仍会产生加载顺序耦合。无明显私有 API 风险。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
7. 把 UIMenuItem 的文字渲染偷换成图标渲染
做了什么:代码给带图标的菜单标题追加 U+FEFF U+200B 隐形后缀。代码交换 NSString.sizeWithAttributes: 与 UILabel 的绘制、布局和 frame setter。代码根据标题反查菜单项,抑制文字并嵌入居中的 UIImageView。
为什么这样做:旧 UIMenuController 的 UIMenuItem 没有图标 API。代码借系统创建的标签视图完成布局,不需要自行复刻菜单、命中测试或动作分派。
关键代码摘录
- (void)_78724db9_layoutSubviews {
UIMenuItem *item = [[UIMenuController sharedMenuController] findImageItemByTitle:self.text];
UIImage *image = item._tg_image;
if (image == nil) {
[self _78724db9_layoutSubviews];
return;
}
CGPoint point = CGPointMake(ceil((self.bounds.size.width - image.size.width) / 2.0),
ceil((self.bounds.size.height - image.size.height) / 2.0));
UIImageView *imageView = [self associatedObjectForKey:imageViewKey];
if (imageView == nil) {
imageView = [[UIImageView alloc] init];
[self addSubview:imageView];
[self setAssociatedObject:imageView forKey:imageViewKey associationPolicy:NSObjectAssociationPolicyRetain];
}
imageView.image = image;
imageView.frame = CGRectMake(point.x, point.y, image.size.width, image.size.height);
}代价与风险:这是对所有 NSString 和 UILabel 实例的全局 hook。它依赖旧菜单内部使用标签。与其他 swizzle 冲突时风险高。无私有 selector,但维护成本高。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
8. 从私有 _scrollHeartbeat 抓显示链接,强制旧系统滚动使用满帧率
做了什么:代码在 iOS 15 上以 KVC 读取 UIScrollView 的私有 _scrollHeartbeat。代码标记该 CADisplayLink,并请求屏幕最大帧率。另一个 CADisplayLink swizzle 会再次把带标记的范围固定到最大值。
混淆还原:@"_" + @"scrollHeartbeat" 还原为 _scrollHeartbeat。源码将该字符串传给 valueForKey:。
为什么这样做:公开滚动 API 不暴露内部心跳显示链接。只给应用自建显示链接设高帧率不会改变 UIKit 滚动。该代码只针对 iOS 15,iOS 16 直接跳过。
关键代码摘录
- (void)fixScrollDisplayLink {
if (@available(iOS 16.0, *)) {
return;
}
static NSString *scrollHeartbeatKey = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
scrollHeartbeatKey = [NSString stringWithFormat:@"_%@", @"scrollHeartbeat"];
});
id value = [self valueForKey:scrollHeartbeatKey];
if ([value isKindOfClass:[CADisplayLink class]]) {
CADisplayLink *displayLink = (CADisplayLink *)value;
if ([displayLink associatedObjectForKey:forceFullRefreshRateKey] == nil) {
[displayLink setAssociatedObject:@true forKey:forceFullRefreshRateKey];
if (@available(iOS 15.0, *)) {
float maxFps = [UIScreen mainScreen].maximumFramesPerSecond;
if (maxFps > 61.0f) {
[displayLink setPreferredFrameRateRange:CAFrameRateRangeMake(maxFps, maxFps, maxFps)];
}
}
}
}
}代价与风险:私有 ivar/KVC 名称可能改变。它改变系统滚动的功耗和调度策略。存在审核与版本兼容风险。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
9. 先读私有方法签名,再选择 float 或 double 的 IMP 调用约定
做了什么:代码动态查找 CASpringAnimation 的私有求值 selector。代码读取第一个显式参数的 Objective-C 类型编码。代码按 float 或 double 选择不同函数指针。找不到时退化为线性值。
混淆还原:@"_" + @"solveForInput:" 还原为 _solveForInput:。源码仅用该 selector 的 IMP 求弹簧在给定相位的值。
为什么这样做:不同系统的私有 ABI 已变化。硬编码一种函数指针会违反调用约定。公开 Core Animation 没有同等的逐相位私有求值器。
关键代码摘录
- (CGFloat)valueAt:(CGFloat)t {
static float (*impl)(id, float) = NULL;
static double (*dimpl)(id, double) = NULL;
dispatch_once(&onceToken, ^{
Method method = class_getInstanceMethod([CASpringAnimation class],
NSSelectorFromString([@"_" stringByAppendingString:@"solveForInput:"]));
if (method) {
const char *encoding = method_getTypeEncoding(method);
NSMethodSignature *signature = [NSMethodSignature signatureWithObjCTypes:encoding];
const char *argType = [signature getArgumentTypeAtIndex:2];
if (strncmp(argType, "f", 1) == 0) {
impl = (float (*)(id, float))method_getImplementation(method);
} else if (strncmp(argType, "d", 1) == 0) {
dimpl = (double (*)(id, double))method_getImplementation(method);
}
}
});
if (impl) return (CGFloat)impl(self, (float)t);
if (dimpl) return (CGFloat)dimpl(self, (double)t);
return t;
}代价与风险:私有 selector 或返回语义改变时,线性退化可能造成曲线不一致。若类型编码出现第三种形式,函数会静默退化。存在审核风险。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:
method_getTypeEncoding→NSMethodSignature→getArgumentTypeAtIndex分支选择。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
10. 给弹簧动画写入私有高帧率原因码
做了什么:代码组合公开的 preferredFrameRateRange 和私有 KVC 键 highFrameRateReason。代码只在 iOS 15 及以上设置这两项。
为什么这样做:仅设置首选帧率范围可能不足以让系统把动画归类为高帧率工作。私有原因码显然参与该内部决策。原因码的具体系统语义未验证。
关键代码摘录
CABasicAnimation * _Nonnull make26SpringAnimationImpl(NSString * _Nonnull keyPath,
double duration) {
CASpringAnimation *springAnimation = [CASpringAnimation animationWithKeyPath:keyPath];
springAnimation.mass = 1.0f;
springAnimation.stiffness = 555.027;
springAnimation.damping = 47.118;
springAnimation.duration = duration;
springAnimation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear];
if (@available(iOS 17.0, *)) {
springAnimation.allowsOverdamping = false;
}
if (@available(iOS 15.0, *)) {
[springAnimation setValue:@(1048619) forKey:@"highFrameRateReason"];
springAnimation.preferredFrameRateRange = CAFrameRateRangeMake(80.0, 120.0, 120.0);
}
return springAnimation;
}代价与风险:highFrameRateReason 是私有 KVC 键。整数 1048619 没有源码内说明。该代码可能增加耗电,且存在审核风险。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
11. 用 NSInvocation 安全探测并配置私有模糊效果
做了什么:代码在 iOS 11 到 iOS 12 解析私有类,并经 NSInvocation 调用存在的 setter。辅助函数先取得 NSMethodSignature。不存在的 setter 会成为无操作。
混淆还原:类名是 _UICustomBlurEffect。setter 依次是 setBlurRadius:、setColorTint:、setColorTintAlpha:、setDarkeningTintAlpha:、setGrayscaleTintAlpha: 和 setSaturationDeltaFactor:。这些都由数组拼接得到。
为什么这样做:旧系统没有满足该参数组合的公开模糊配置。NSInvocation 比把不同标量类型强转为单一 performSelector: 更安全。iOS 13 及以上改用公开材质样式。
关键代码摘录
NSString *string = [@[@"_", @"UI", @"Custom", @"BlurEffect"] componentsJoinedByString:@""];
CustomBlurEffect *result = (CustomBlurEffect *)[NSClassFromString(string) effectWithStyle:0];
setField(result, [@[@"set", @"BlurRadius", @":"] componentsJoinedByString:@""], 10.0);
setNilField(result, [@[@"set", @"Color", @"Tint", @":"] componentsJoinedByString:@""]);
setField(result, [@[@"set", @"Color", @"Tint", @"Alpha", @":"] componentsJoinedByString:@""], 0.0);
setField(result, [@[@"set", @"Darkening", @"Tint", @"Alpha", @":"] componentsJoinedByString:@""], 0.0);
setField(result, [@[@"set", @"Grayscale", @"Tint", @"Alpha", @":"] componentsJoinedByString:@""], 0.0);
setField(result, [@[@"set", @"Saturation", @"Delta", @"Factor", @":"] componentsJoinedByString:@""], 1.0);
if ([UIScreen mainScreen].scale > 2.5f) {
setField(result, @"setScale:", 0.3);
} else {
setField(result, @"setScale:", 0.5);
}
return result;代价与风险:私有类和 setter 有审核风险。返回对象若为空,后续辅助函数依赖 Objective-C 向 nil 发消息。渲染效果细节不在本笔记范围。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
12. 将私有 _UIPortalView 包装为最小协议,并关闭其命中测试转发
做了什么:代码拼接并解析私有门户视图类。代码以本地 UIKitPortalViewProtocol 访问已知属性。代码把门户设为不接收触摸,并按需要同步源视图的位置和变换。
混淆还原:数组拼接结果是 _UIPortalView。代码还通过同样的解析确认一个视图是否为该类,并读取其 sourceView。
为什么这样做:公开 UIKit 没有对应的源视图门户视图。最小协议避免直接把私有头文件引入编译期。类缺失时调用者得到 nil。
关键代码摘录
static Class portalViewClass = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
portalViewClass = NSClassFromString([@[@"_", @"UI", @"Portal", @"View"] componentsJoinedByString:@""]);
});
if (!portalViewClass) {
return nil;
}
UIView<UIKitPortalViewProtocol> *view = [[portalViewClass alloc] init];
if (!view) {
return nil;
}
if (@available(iOS 14.0, *)) {
view.forwardsClientHitTestingToSourceView = false;
}
view.matchesPosition = matchPosition;
view.matchesTransform = matchPosition;
view.matchesAlpha = false;
if (@available(iOS 14.0, *)) {
view.allowsHitTesting = false;
}
return view;代价与风险:属性和类都未公开。类存在不等于全部属性仍兼容。存在审核和版本兼容风险。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
13. 检验私有方法的完整类型编码后才替换 IMP
做了什么:代码仅在 iOS 26 及以上注册。代码解析 UIKit Swift mangled 类名,再找 backdropLayer:didChangeLuma:。代码要求完整编码正好是 v32@0:8@16d24 后,才保存原 IMP 并替换。
混淆还原:TGEncodeText 的键为 0,所以它没有改变字符串。真实类名是 _TtC5UIKitP33_ACD4A08F4BE9D00246F2A9C24A80CA8817UISDFBackdropView。拼接的 selector 是 backdropLayer:didChangeLuma:。
为什么这样做:私有 Swift 类名和签名都可能变化。仅以 selector 存在就强转 IMP 可能破坏寄存器传参。完整编码检查减少了 ABI 不匹配风险。
关键代码摘录
static void registerEffectViewOverrides(void) {
NSMutableArray<NSString *> *nameList = [[NSMutableArray alloc] init];
[nameList addObject:TGEncodeText(@"_TtC5UIKitP33_ACD4A08F4BE9D00246F2A9C24A80CA8817UISDFBackdropView", 0)];
NSString *selectorString = [@"backdropLayer" stringByAppendingString:@":didChangeLuma:"];
for (NSString *name in nameList) {
Class classValue = NSClassFromString(name);
if (classValue == nil) {
continue;
}
Method method = (Method)[RuntimeUtils getMethodOfClass:classValue
selector:NSSelectorFromString(selectorString)];
if (method) {
const char *typeEncoding = method_getTypeEncoding(method);
if (strcmp(typeEncoding, "v32@0:8@16d24") == 0) {
original_backdropLayerDidChangeLuma =
(id (*)(id, SEL, CALayer *, double))method_getImplementation(method);
[RuntimeUtils replaceMethodImplementationOfClass:classValue
selector:NSSelectorFromString(selectorString)
replacement:(IMP)&replacement_backdropLayerDidChangeLuma];
}
}
break;
}
}代价与风险:这是私有 UIKit 实现,并永久覆盖原 IMP。方法实现仍可能在类型编码不变时改变语义。液态玻璃视觉细节不在本笔记范围。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:
method_getTypeEncoding比较v32@0:8@16d24后才保存原 IMP 与替换。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
14. 通过私有 selector 让悬浮窗口既不改状态栏,也不夺取键盘焦点
做了什么:AboveStatusBarWindow 处于状态栏之上。代码在类初始化时,给它动态添加两个私有 selector。两个 selector 都复用返回 false 的公开命名实现。
混淆还原:第一个 selector 是 _canAffectStatusBarAppearance。第二个 selector 是 _canBecomeKeyWindow。源码分别由数组拼接和前缀拼接得到。
为什么这样做:仅重写公开 canBecomeKeyWindow 不保证 UIKit 会查询它的私有变体。动态补齐私有 selector 让覆盖窗口可点击,同时把其他点击穿透,并避免影响全局状态栏或成为 key window。
关键代码摘录
- (BOOL)shouldAffectStatusBarAppearance {
return false;
}
- (BOOL)canBecomeKeyWindow {
return false;
}
+ (void)initialize {
NSString *canAffectSelectorString = [@[@"_can", @"Affect", @"Status", @"Bar", @"Appearance"] componentsJoinedByString:@""];
SEL canAffectSelector = NSSelectorFromString(canAffectSelectorString);
Method shouldAffectMethod = class_getInstanceMethod(self, @selector(shouldAffectStatusBarAppearance));
IMP canAffectImplementation = method_getImplementation(shouldAffectMethod);
class_addMethod(self, canAffectSelector, canAffectImplementation,
method_getTypeEncoding(shouldAffectMethod));
NSString *canBecomeKeySelectorString = [NSString stringWithFormat:@"_%@", NSStringFromSelector(@selector(canBecomeKeyWindow))];
SEL canBecomeKeySelector = NSSelectorFromString(canBecomeKeySelectorString);
Method canBecomeKeyMethod = class_getInstanceMethod(self, @selector(canBecomeKeyWindow));
IMP canBecomeKeyImplementation = method_getImplementation(canBecomeKeyMethod);
class_addMethod(self, canBecomeKeySelector, canBecomeKeyImplementation,
method_getTypeEncoding(canBecomeKeyMethod));
}代价与风险:私有 selector 存在审核风险。系统若改为其他判定接口,窗口行为会变化。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
15. Debug 录制器直接替换 UIKit 基类 IMP,采集原始滚动物理数据
做了什么:调试专用录制器把 UIPanGestureRecognizer 的四个触摸入口替换为 block IMP。代码先执行原实现,再记录已经更新的速度和状态。代码还 hook setContentOffset:,以及存在时的私有橡皮筋计算器。
为什么这样做:采样计时器会丢失 UIKit 实际写入的每个 offset。先后顺序使触摸数据与 UIKit 刚算出的识别器状态配对。实例身份过滤让全局 hook 只记录附着的一个滚动视图。
关键代码摘录
for (name, phase) in phases {
let sel = NSSelectorFromString(name)
guard let method = class_getInstanceMethod(UIPanGestureRecognizer.self, sel) else { continue }
typealias Fn = @convention(c) (AnyObject, Selector, AnyObject, AnyObject) -> Void
let original = unsafeBitCast(method_getImplementation(method), to: Fn.self)
let block: @convention(block) (AnyObject, AnyObject, AnyObject) -> Void = { obj, touches, event in
original(obj, sel, touches, event)
let t = (event as? UIEvent)?.timestamp ?? CACurrentMediaTime()
CaptureSink.current?.recordTouch(phase: phase, from: obj, eventTime: t)
}
method_setImplementation(method, imp_implementationWithBlock(block))
}
// ...
let sel = NSSelectorFromString("_rubberBandOffsetForOffset:maxOffset:minOffset:range:outside:")
guard let method = class_getInstanceMethod(UIScrollView.self, sel) else { return }
typealias Fn = @convention(c) (AnyObject, Selector, CGFloat, CGFloat, CGFloat, CGFloat,
UnsafeMutablePointer<ObjCBool>?) -> CGFloat
let original = unsafeBitCast(method_getImplementation(method), to: Fn.self)
// 录制原结果后,block 返回 out。
method_setImplementation(method, imp_implementationWithBlock(block))代价与风险:源码明确标为 debug-only,且不应出货。method_setImplementation 不可逆。私有 _rubberBandOffsetForOffset:maxOffset:minOffset:range:outside: 有审核风险。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
16. 关闭 UITextView 分块渲染,并识别 iOS 16 私有自动更正提示
做了什么:构造输入框时,调用存在才执行的私有 _disableTiledViews。iOS 17 前,代码加一个不延迟也不取消触摸的 tap recognizer。它会把 @"UI" + @"AutocorrectInlinePrompt" 解析为 UIAutocorrectInlinePrompt,命中后执行清理回调。
为什么这样做:分块文本渲染会妨碍需要完整内容的特定输入场景。私有自动更正浮层不是业务代码可直接控制的对象。透明 tap recognizer 不拦截原始 UITextView 触摸流。
关键代码摘录
if (disableTiling) {
SEL selector = NSSelectorFromString(@"_disableTiledViews");
if (selector && [self respondsToSelector:selector]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
[self performSelector:selector];
#pragma clang diagnostic pop
}
}
if (@available(iOS 17.0, *)) {
} else {
_tapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self
action:@selector(workaroundTapGesture:)];
_tapRecognizer.cancelsTouchesInView = false;
_tapRecognizer.delaysTouchesBegan = false;
_tapRecognizer.delaysTouchesEnded = false;
_tapRecognizer.delegate = self;
[self addGestureRecognizer:_tapRecognizer];
}代价与风险:_disableTiledViews 与 UIAutocorrectInlinePrompt 都是私有实现。禁用分块渲染可能增大长文本内存和绘制成本。手势竞争仍可能随 iOS 改变。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
17. 旧系统用 KVC 强制设备方向,新系统改走场景几何更新
做了什么:代码将方向策略切为仅竖屏时,在 iOS 16 以上请求公开的 UIWindowScene.GeometryPreferences。旧系统则向 UIDevice 的 KVC 键 orientation 写入竖屏 raw value。
为什么这样做:旧 UIKit 没有场景几何更新 API。只调用 attemptRotationToDeviceOrientation 无法把物理方向强制改为竖屏。新版路径避免了旧 KVC hack。
关键代码摘录
var orientations: UIInterfaceOrientationMask = defaultOrientations {
didSet {
if oldValue != self.orientations {
if self.orientations == .portrait {
if #available(iOSApplicationExtension 16.0, iOS 16.0, *) {
let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene
windowScene?.requestGeometryUpdate(.iOS(interfaceOrientations: .portrait))
self.setNeedsUpdateOfSupportedInterfaceOrientations()
} else if UIDevice.current.orientation != .portrait {
let value = UIInterfaceOrientation.portrait.rawValue
UIDevice.current.setValue(value, forKey: "orientation")
}
} else {
if #available(iOSApplicationExtension 16.0, iOS 16.0, *) {
self.setNeedsUpdateOfSupportedInterfaceOrientations()
} else {
UIViewController.attemptRotationToDeviceOrientation()
}
}
}
}
}代价与风险:UIDevice.orientation 的 KVC 写入未公开,存在审核风险。代码选择 connectedScenes.first,多窗口时未必是期望场景。旧路径的系统行为未验证。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
18. 重写私有 UIWindow 方向回调,精确包住布局中的旋转状态
做了什么:NativeWindow 覆盖私有 _update(toInterfaceOrientation:duration:force:)。代码在 super 前后切换布局中标志。代码在结束后把转换后的方向回传给自定义窗口宿主。
为什么这样做:普通 layoutSubviews 缺少“当前布局由方向切换触发”的边界。围绕 UIKit 私有回调包住标志,可以让上层避免在转场中错误触发自己的布局逻辑。
关键代码摘录
override func layoutSubviews() {
super.layoutSubviews()
self.layoutSubviewsEvent?()
}
override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
super.motionEnded(motion, with: event)
if motion == .motionShake {
self.motionShakeImpl?()
}
}
override func _update(toInterfaceOrientation arg1: Int32,
duration arg2: Double, force arg3: Bool) {
self.updateIsUpdatingOrientationLayout?(true)
super._update(toInterfaceOrientation: arg1, duration: arg2, force: arg3)
self.updateIsUpdatingOrientationLayout?(false)
let orientation = UIInterfaceOrientation(rawValue: Int(arg1)) ?? .unknown
self.updateToInterfaceOrientation?(orientation)
}代价与风险:私有覆盖对 SDK 和运行时签名高度敏感。方法签名或调用时机改变可能崩溃。存在审核风险。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
19. 对运行时类表做哈希索引,避免私有类名进入二进制
做了什么:Freedom 枚举全部 Objective-C 类。代码用带固定 seed 的 MurmurHash3 把类名映射到 uint32_t。调用点只保留哈希常量,再从映射获得运行时类。
为什么这样做:这比 NSClassFromString(@"私有类名") 更难把符号明文留在二进制。查找表只创建一次,后续查询是映射查询。
关键代码摘录
Class freedomClass(uint32_t name)
{
static std::map<uint32_t, __unsafe_unretained Class> classMap;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^
{
int classCount = freedom_getClassList(NULL, 0);
if (classCount > 0)
{
__unsafe_unretained Class *classList = (Class *)malloc(classCount * sizeof(Class));
objc_getClassList(classList, classCount);
for (int i = 0; i < classCount; i++) {
const char *className = freedom_class_getName(classList[i]);
uint32_t hashName = (uint32_t)murMurHashBytes32((void *)className,
(int)strlen(className));
classMap[hashName] = classList[i];
}
free(classList);
}
freedom_initialized = true;
});
auto it = classMap.find(name);
if (it != classMap.end()) return it->second;
return nil;
}代价与风险:哈希碰撞会选错类。系统类名变化会使查找失败。FreedomUIKit.m 中部分哈希对应的真实私有类名无法仅由本次源码确认,因此未验证。该混淆不能消除私有 API 的审核风险。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
20. 自动生成装饰子类,再从 alloc 时替换每个实例的 ISA
做了什么:代码动态创建 Decorated<hash> 子类。代码从另一个类复制指定方法到装饰类。代码把原类元类的 alloc 替换为 freedomAllocImpl,所以每个新实例都会在分配后被 object_setClass 成装饰类。
为什么这样做:它不需改写目标类或全局交换同名方法。adjustDecoratedClass 还会为继承层次生成适配装饰类。这样可给未知私有类的实例附加少量覆盖。
关键代码摘录
id freedomAllocImpl(id self, SEL _cmd)
{
static id (*nativeImp)(id, SEL) = NULL;
dispatch_once(&onceToken, ^
{
nativeImp = (id (*)(id, SEL))class_getMethodImplementation(
object_getClass([NSObject class]), @selector(alloc));
});
id result = nativeImp(self, _cmd);
Class decoratedClass = adjustDecoratedClass(self);
if (decoratedClass != Nil)
object_setClass(result, decoratedClass);
return result;
}
// ...
Class decoratedClass = objc_allocateClassPair(className,
[[NSString alloc] initWithFormat:@"Decorated%" PRIx32 "", name].UTF8String, 0);
Method *methodList = class_copyMethodList(className, &methodCount);代价与风险:这是高风险 ISA-swizzling。alloc 的所有权和类簇假设都可能被破坏。动态类名、哈希 selector 和私有对象布局都提高维护成本。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
21. 直接改写系统返回手势识别器的 ISA,增加全屏导航手势
做了什么:代码把 UIKit 创建的 interactivePopGestureRecognizer 改成自定义 TGNavigationPanGestureRecognizer。代码保留系统原 delegate,并以代理注入自定义开始和同时识别判断。遇到 QLPreviewController 时,代码又暂时改回 UIScreenEdgePanGestureRecognizer。
为什么这样做:系统返回手势的类与边缘限制通常不可替换。ISA 改写让既有手势对象保留 UIKit 已配置的 target 和状态,同时拥有自定义 pan 行为。
关键代码摘录
if (iosMajorVersion() >= 8 && !TGIsRTL())
{
object_setClass(self.interactivePopGestureRecognizer,
[TGNavigationPanGestureRecognizer class]);
self.interactivePopGestureRecognizer.delaysTouchesBegan = false;
self.interactivePopGestureRecognizer.delaysTouchesEnded = true;
self.interactivePopGestureRecognizer.cancelsTouchesInView = true;
_gestureDelegate = [[TGNavigationGestureDelegateProxy alloc] init];
_gestureDelegate.delegate = self.interactivePopGestureRecognizer.delegate;
__weak TGNavigationController *weakSelf = self;
_gestureDelegate.shouldBegin = ^bool(TGNavigationPanGestureRecognizer *gestureRecognizer)
{
__strong TGNavigationController *strongSelf = weakSelf;
if (strongSelf != nil)
{
bool shouldBegin = [strongSelf _gestureRecognizerShouldBegin:gestureRecognizer];代价与风险:对象真实布局必须与替换类兼容。系统私有手势子类变化会崩溃。该 legacy 路径还有 RTL 与 iOS 版本条件,维护成本高。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
22. 把 NSNotificationCenter 的同步投递改造成可截获的中间件
做了什么:代码全局交换通知中心的同步投递方法。主线程处理器可先做工作、调用 passNotification 决定投递时机,或返回 true 完全吞掉投递。方向处理模块用它在旋转通知前后维护状态。
为什么这样做:普通 observer 只能在通知已投递时被调用。该中间件可改变原通知与自定义操作的先后顺序,且可不改变非主线程投递。
关键代码摘录
- (void)_a65afc19_postNotificationName:(NSString *)aName
object:(id)anObject
userInfo:(NSDictionary *)aUserInfo
{
if ([NSThread isMainThread]) {
for (NotificationHandlerBlock handler in notificationHandlers())
{
if (handler(aName, anObject, aUserInfo, ^{
[self _a65afc19_postNotificationName:aName object:anObject userInfo:aUserInfo];
})) {
return;
}
}
}
[self _a65afc19_postNotificationName:aName object:anObject userInfo:aUserInfo];
}
// ...
[RuntimeUtils swizzleInstanceMethodOfClass:[NSNotificationCenter class]
currentSelector:@selector(postNotificationName:object:userInfo:)
newSelector:@selector(_a65afc19_postNotificationName:object:userInfo:)];代价与风险:它影响进程内每个主线程同步通知。重入、处理器顺序和异常都会影响全局行为。swizzle 名 _a65afc19_ 是工程命名,不是私有 API 混淆。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
23. 把截图重复通知去重状态放在每个 observer,避免覆盖层漏报
做了什么:代码以每个 ScreenshotNotificationObserver 自己的最后时间戳去重。相隔少于 0.5 秒的相同截图通知会被丢弃。
为什么这样做:源码注释记录 UIKit 曾为同一次截图连续投递两次。共享全局时间戳会让聊天和覆盖其上的媒体画廊互相吞掉合法通知。实例级状态同时解决重复与多观察者问题。
关键代码摘录
private let screenshotRepeatInterval: CFAbsoluteTime = 0.5
private final class ScreenshotNotificationObserver {
private var observer: NSObjectProtocol?
private var lastTimestamp: CFAbsoluteTime?
init(_ f: @escaping () -> Void) {
self.observer = NotificationCenter.default.addObserver(
forName: UIApplication.userDidTakeScreenshotNotification,
object: nil, queue: .main, using: { [weak self] _ in
guard let self else { return }
let timestamp = CFAbsoluteTimeGetCurrent()
if let lastTimestamp = self.lastTimestamp,
timestamp - lastTimestamp < screenshotRepeatInterval {
return
}
self.lastTimestamp = timestamp
f()
})
}
}代价与风险:0.5 秒内的两次真实截图会被合并。该路径只影响事件报告,不会阻止截图。无明显私有 API 风险。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
24. 只把 CAFilter 当作运行时工厂,不引入私有头文件
做了什么:代码以本地 GraphicsFilterProtocol 描述唯一需要的类方法。代码用 NSClassFromString(@"CAFilter") 解析私有 Core Animation 类。各工厂直接按滤镜名调用 filterWithName:。
为什么这样做:最小协议让编译器无需私有 SDK 头文件。类解析失败时 Objective-C 给 nil 发消息,工厂返回空值。调用方可选择不安装滤镜。
关键代码摘录
@protocol GraphicsFilterProtocol <NSObject>
- (NSObject * _Nullable)filterWithName:(NSString * _Nonnull)name;
@end
NSObject * _Nullable makeBlurFilter() {
return [(id<GraphicsFilterProtocol>)NSClassFromString(@"CAFilter")
filterWithName:@"gaussianBlur"];
}
NSObject * _Nullable makeVariableBlurFilter() {
return [(id<GraphicsFilterProtocol>)NSClassFromString(@"CAFilter")
filterWithName:@"variableBlur"];
}
NSObject * _Nullable makeLuminanceToAlphaFilter() {
return [(id<GraphicsFilterProtocol>)NSClassFromString(@"CAFilter")
filterWithName:@"luminanceToAlpha"];
}代价与风险:CAFilter 和滤镜名均为私有接口。存在审核和系统兼容风险。本条只记录私有 API 的获取方式,不分析视觉效果细节。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。