From ba5e0a4c38f31df89603674774f7af47e0b347a4 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Sun, 30 Mar 2025 22:12:19 +0200 Subject: [PATCH 01/18] Enforced arm64 or x86_64 architecture --- .../ITKSuperBuilder/include/ITKSuperBuilder.h | 20 +++---- Sources/ITKSuperBuilder/src/ITKSuperBuilder.m | 24 +------- .../InterposeSubclass.swift | 18 +++--- .../ObjectHookStrategy.swift | 60 ++++++------------- Sources/InterposeKit/Interpose.swift | 4 ++ Sources/InterposeKit/InterposeError.swift | 5 -- 6 files changed, 40 insertions(+), 91 deletions(-) diff --git a/Sources/ITKSuperBuilder/include/ITKSuperBuilder.h b/Sources/ITKSuperBuilder/include/ITKSuperBuilder.h index 2260aa9..bd31b83 100644 --- a/Sources/ITKSuperBuilder/include/ITKSuperBuilder.h +++ b/Sources/ITKSuperBuilder/include/ITKSuperBuilder.h @@ -1,7 +1,9 @@ -#if __APPLE__ -#import +#if !(defined(__APPLE__) && (defined(__arm64__) || defined(__x86_64__))) +#error "[InterposeKit] Supported only on Apple platforms with arm64 or x86_64 architecture." #endif +#import + NS_ASSUME_NONNULL_BEGIN /** @@ -44,7 +46,7 @@ There are a few important details: @see https://steipete.com/posts/calling-super-at-runtime/ */ -@interface ITKSuperBuilder : NSObject +@interface ITKSuperBuilder: NSObject /// Adds an empty super implementation instance method to originalClass. /// If a method already exists, this will return NO and a descriptive error message. @@ -53,22 +55,14 @@ There are a few important details: error:(NSError **)error; /// Check if the instance method in `originalClass` is a super trampoline. -+ (BOOL)isSuperTrampolineForClass:(Class)originalClass selector:(SEL)selector; - -/// x86-64 and ARM64 are currently supported. -@property(class, readonly) BOOL isSupportedArchitecture; - -#if (defined (__arm64__) || defined (__x86_64__)) && __APPLE__ -/// Helper that does not exist if architecture is not supported. -+ (BOOL)isCompileTimeSupportedArchitecture; -#endif ++ (BOOL)isSuperTrampolineForClass:(Class)originalClass + selector:(SEL)selector; @end NSString *const ITKSuperBuilderErrorDomain; typedef NS_ERROR_ENUM(ITKSuperBuilderErrorDomain, ITKSuperBuilderErrorCode) { - SuperBuilderErrorCodeArchitectureNotSupported, SuperBuilderErrorCodeNoSuperClass, SuperBuilderErrorCodeNoDynamicallyDispatchedMethodAvailable, SuperBuilderErrorCodeFailedToAddMethod diff --git a/Sources/ITKSuperBuilder/src/ITKSuperBuilder.m b/Sources/ITKSuperBuilder/src/ITKSuperBuilder.m index 5f9c0d9..9027c8e 100644 --- a/Sources/ITKSuperBuilder/src/ITKSuperBuilder.m +++ b/Sources/ITKSuperBuilder/src/ITKSuperBuilder.m @@ -17,9 +17,9 @@ static IMP ITKGetTrampolineForTypeEncoding(__unused const char *typeEncoding) { BOOL requiresStructDispatch = NO; #if defined (__arm64__) - // ARM64 doesn't use stret dispatch. Yay! + // arm64 doesn't use stret dispatch. Yay! #elif defined (__x86_64__) - // On x86-64, stret dispatch is ~used whenever return type doesn't fit into two registers + // On x86_64, stret dispatch is ~used whenever return type doesn't fit into two registers // // http://www.sealiesoftware.com/blog/archive/2008/10/30/objc_explain_objc_msgSend_stret.html // x86_64 is more complicated, including rules for returning floating-point struct fields in FPU registers, and ppc64's rules and exceptions will make your head spin. The gory details are documented in the Mac OS X ABI Guide, though as usual if the documentation and the compiler disagree then the documentation is wrong. @@ -41,32 +41,12 @@ static IMP ITKGetTrampolineForTypeEncoding(__unused const char *typeEncoding) { @implementation ITKSuperBuilder -+ (BOOL)isSupportedArchitecture { -#if defined (__arm64__) || defined (__x86_64__) - return YES; -#else - return NO; -#endif -} - -#if defined (__arm64__) || defined (__x86_64__) -+ (BOOL)isCompileTimeSupportedArchitecture { - return [self isSupportedArchitecture]; -} -#endif - + (BOOL)isSuperTrampolineForClass:(Class)originalClass selector:(SEL)selector { - // No architecture check needed - will just be NO. let method = class_getInstanceMethod(originalClass, selector); return ITKMethodIsSuperTrampoline(method); } + (BOOL)addSuperInstanceMethodToClass:(Class)originalClass selector:(SEL)selector error:(NSError **)error { - if (!self.isSupportedArchitecture) { - let msg = @"Unsupported Architecture. (Support includes ARM64 and x86-64 )"; - ERROR_AND_RETURN(SuperBuilderErrorCodeArchitectureNotSupported, msg) - } - // Check that class has a superclass let superClass = class_getSuperclass(originalClass); if (superClass == nil) { diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/InterposeSubclass.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/InterposeSubclass.swift index 95facf8..3f47934 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/InterposeSubclass.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/InterposeSubclass.swift @@ -7,9 +7,6 @@ class InterposeSubclass { static let subclassSuffix = "InterposeKit_" } - /// The object that is being hooked. - let object: AnyObject - /// Subclass that we create on the fly private(set) var dynamicClass: AnyClass @@ -19,8 +16,15 @@ class InterposeSubclass { /// Making KVO and Object-based hooking work at the same time is difficult. /// If we make a dynamic subclass over KVO, invalidating the token crashes in cache_getImp. init(object: AnyObject) throws { - self.object = object - self.dynamicClass = try Self.getExistingSubclass(object: object) ?? Self.createSubclass(object: object) + let dynamicClass: AnyClass = try { () throws -> AnyClass in + if let dynamicClass = Self.getExistingSubclass(object: object) { + return dynamicClass + } + + return try Self.createSubclass(object: object) + }() + + self.dynamicClass = dynamicClass } private static func createSubclass(object: AnyObject) throws -> AnyClass { @@ -63,10 +67,6 @@ class InterposeSubclass { return nil } - class var supportsSuperTrampolines: Bool { - ITKSuperBuilder.isSupportedArchitecture - } - func addSuperTrampoline(selector: Selector) { do { try ITKSuperBuilder.addSuperInstanceMethod(to: dynamicClass, selector: selector) diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift index 1c2e3e3..3b43636 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift @@ -30,9 +30,6 @@ final class ObjectHookStrategy: HookStrategy { /// Subclass that we create on the fly var interposeSubclass: InterposeSubclass? - // Logic switch to use super builder - let generatesSuperIMP = InterposeSubclass.supportsSuperTrampolines - var dynamicSubclass: AnyClass { interposeSubclass!.dynamicClass } @@ -75,46 +72,25 @@ final class ObjectHookStrategy: HookStrategy { let classImplementsMethod = class_implementsInstanceMethod(self.dynamicSubclass, self.selector) let encoding = method_getTypeEncoding(method) - if self.generatesSuperIMP { - // If the subclass is empty, we create a super trampoline first. - // If a hook already exists, we must skip this. - if !classImplementsMethod { - // TODO: Make this failable - self.interposeSubclass!.addSuperTrampoline(selector: self.selector) - } - - // Replace IMP (by now we guarantee that it exists) - self.storedOriginalIMP = class_replaceMethod(self.dynamicSubclass, self.selector, hookIMP, encoding) - guard self.storedOriginalIMP != nil else { - // This should not happen if the class implements the method or we have installed - // the super trampoline. Instead, we should make the trampoline implementation - // failable. - throw InterposeError.implementationNotFound( - class: self.dynamicSubclass, - selector: self.selector - ) - } - Interpose.log("Added -[\(self.class).\(self.selector)] IMP: \(self.storedOriginalIMP!) -> \(hookIMP)") - } else { - // Could potentially be unified in the code paths - if classImplementsMethod { - self.storedOriginalIMP = class_replaceMethod(self.dynamicSubclass, self.selector, hookIMP, encoding) - if self.storedOriginalIMP != nil { - Interpose.log("Added -[\(self.class).\(self.selector)] IMP: \(hookIMP) via replacement") - } else { - Interpose.log("Unable to replace: -[\(self.class).\(self.selector)] IMP: \(hookIMP)") - throw InterposeError.unableToAddMethod(self.class, self.selector) - } - } else { - let didAddMethod = class_addMethod(self.dynamicSubclass, self.selector, hookIMP, encoding) - if didAddMethod { - Interpose.log("Added -[\(self.class).\(self.selector)] IMP: \(hookIMP)") - } else { - Interpose.log("Unable to add: -[\(self.class).\(self.selector)] IMP: \(hookIMP)") - throw InterposeError.unableToAddMethod(self.class, self.selector) - } - } + // If the subclass is empty, we create a super trampoline first. + // If a hook already exists, we must skip this. + if !classImplementsMethod { + // TODO: Make this failable + self.interposeSubclass!.addSuperTrampoline(selector: self.selector) + } + + // Replace IMP (by now we guarantee that it exists) + self.storedOriginalIMP = class_replaceMethod(self.dynamicSubclass, self.selector, hookIMP, encoding) + guard self.storedOriginalIMP != nil else { + // This should not happen if the class implements the method or we have installed + // the super trampoline. Instead, we should make the trampoline implementation + // failable. + throw InterposeError.implementationNotFound( + class: self.dynamicSubclass, + selector: self.selector + ) } + Interpose.log("Added -[\(self.class).\(self.selector)] IMP: \(self.storedOriginalIMP!) -> \(hookIMP)") } func restoreImplementation() throws { diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index ca02b73..f4eefe0 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -1,3 +1,7 @@ +#if !(arch(arm64) || arch(x86_64)) +#error("[InterposeKit] This code only supports arm64 and x86_64 architectures.") +#endif + import ObjectiveC /// Interpose is a modern library to swizzle elegantly in Swift. diff --git a/Sources/InterposeKit/InterposeError.swift b/Sources/InterposeKit/InterposeError.swift index 384dd01..f305bcb 100644 --- a/Sources/InterposeKit/InterposeError.swift +++ b/Sources/InterposeKit/InterposeError.swift @@ -48,9 +48,6 @@ public enum InterposeError: LocalizedError { /// Unable to register subclass for object-based interposing. case failedToAllocateClassPair(class: AnyClass, subclassName: String) - /// Unable to add method for object-based interposing. - case unableToAddMethod(AnyClass, Selector) - /// Object-based hooking does not work if an object is using KVO. /// The KVO mechanism also uses subclasses created at runtime but doesn't check for additional overrides. /// Adding a hook eventually crashes the KVO management code so we reject hooking altogether in this case. @@ -89,8 +86,6 @@ extension InterposeError: Equatable { return "Unexpected Implementation in -[\(klass) \(selector)]: \(String(describing: IMP))" case .failedToAllocateClassPair(let klass, let subclassName): return "Failed to allocate class pair: \(klass), \(subclassName)" - case .unableToAddMethod(let klass, let selector): - return "Unable to add method: -[\(klass) \(selector)]" case .kvoDetected(let obj): return "Unable to hook object that uses Key Value Observing: \(obj)" case .objectPosingAsDifferentClass(let obj, let actualClass): From 94dd469f418f7a1728c112b0d2b26932d55485cf Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Sun, 30 Mar 2025 22:32:36 +0200 Subject: [PATCH 02/18] More clean-up in ITKSuperBuilder --- Sources/ITKSuperBuilder/src/ITKSuperBuilder.m | 144 ++++++++++-------- 1 file changed, 84 insertions(+), 60 deletions(-) diff --git a/Sources/ITKSuperBuilder/src/ITKSuperBuilder.m b/Sources/ITKSuperBuilder/src/ITKSuperBuilder.m index 9027c8e..5b1eeeb 100644 --- a/Sources/ITKSuperBuilder/src/ITKSuperBuilder.m +++ b/Sources/ITKSuperBuilder/src/ITKSuperBuilder.m @@ -1,77 +1,74 @@ -#if __APPLE__ #import "ITKSuperBuilder.h" @import ObjectiveC.message; @import ObjectiveC.runtime; -NS_ASSUME_NONNULL_BEGIN - -NSString *const ITKSuperBuilderErrorDomain = @"com.steipete.InterposeKit"; - -void msgSendSuperTrampoline(void); -void msgSendSuperStretTrampoline(void); - #define let const __auto_type #define var __auto_type -static IMP ITKGetTrampolineForTypeEncoding(__unused const char *typeEncoding) { - BOOL requiresStructDispatch = NO; - #if defined (__arm64__) - // arm64 doesn't use stret dispatch. Yay! - #elif defined (__x86_64__) - // On x86_64, stret dispatch is ~used whenever return type doesn't fit into two registers - // - // http://www.sealiesoftware.com/blog/archive/2008/10/30/objc_explain_objc_msgSend_stret.html - // x86_64 is more complicated, including rules for returning floating-point struct fields in FPU registers, and ppc64's rules and exceptions will make your head spin. The gory details are documented in the Mac OS X ABI Guide, though as usual if the documentation and the compiler disagree then the documentation is wrong. - NSUInteger returnTypeActualSize = 0; - NSGetSizeAndAlignment(typeEncoding, &returnTypeActualSize, NULL); - requiresStructDispatch = returnTypeActualSize > (sizeof(void *) * 2); - #else - // Unknown architecture - // https://devblogs.microsoft.com/xamarin/apple-new-processor-architecture/ - // watchOS uses arm64_32 since series 4, before armv7k. watch Simulator uses i386. - // See ILP32: http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.dai0490a/ar01s01.html - #endif +NS_ASSUME_NONNULL_BEGIN - return requiresStructDispatch ? (IMP)msgSendSuperStretTrampoline : (IMP)msgSendSuperTrampoline; -} +NSString *const ITKSuperBuilderErrorDomain = @"com.steipete.InterposeKit"; -#define ERROR_AND_RETURN(CODE, STRING)\ -if (error) { *error = [NSError errorWithDomain:ITKSuperBuilderErrorDomain code:CODE userInfo:@{NSLocalizedDescriptionKey: STRING}];} return NO; +static IMP ITKGetTrampolineForTypeEncoding(__unused const char *typeEncoding); +static BOOL ITKMethodIsSuperTrampoline(Method method); @implementation ITKSuperBuilder -+ (BOOL)isSuperTrampolineForClass:(Class)originalClass selector:(SEL)selector { - let method = class_getInstanceMethod(originalClass, selector); - return ITKMethodIsSuperTrampoline(method); -} - -+ (BOOL)addSuperInstanceMethodToClass:(Class)originalClass selector:(SEL)selector error:(NSError **)error { ++ (BOOL)addSuperInstanceMethodToClass:(Class)originalClass + selector:(SEL)selector + error:(NSError **)error +{ // Check that class has a superclass - let superClass = class_getSuperclass(originalClass); - if (superClass == nil) { - let msg = [NSString stringWithFormat:@"Unable to find superclass for %@", NSStringFromClass(originalClass)]; - ERROR_AND_RETURN(SuperBuilderErrorCodeNoSuperClass, msg) + let superclass = class_getSuperclass(originalClass); + + if (superclass == nil) { + if (error) { + let message = [NSString stringWithFormat:@"Unable to find superclass for %@", NSStringFromClass(originalClass)]; + *error = [NSError errorWithDomain:ITKSuperBuilderErrorDomain + code:SuperBuilderErrorCodeNoSuperClass + userInfo:@{NSLocalizedDescriptionKey: message}]; + return NO; + } } - + // Fetch method called with super - let method = class_getInstanceMethod(superClass, selector); + let method = class_getInstanceMethod(superclass, selector); if (method == NULL) { - let msg = [NSString stringWithFormat:@"No dynamically dispatched method with selector %@ is available on any of the superclasses of %@", NSStringFromSelector(selector), NSStringFromClass(originalClass)]; - ERROR_AND_RETURN(SuperBuilderErrorCodeNoDynamicallyDispatchedMethodAvailable, msg) + if (error) { + let message = [NSString stringWithFormat:@"No dynamically dispatched method with selector %@ is available on any of the superclasses of %@", NSStringFromSelector(selector), NSStringFromClass(originalClass)]; + *error = [NSError errorWithDomain:ITKSuperBuilderErrorDomain + code:SuperBuilderErrorCodeNoDynamicallyDispatchedMethodAvailable + userInfo:@{NSLocalizedDescriptionKey: message}]; + return NO; + } } - + // Add trampoline let typeEncoding = method_getTypeEncoding(method); let trampoline = ITKGetTrampolineForTypeEncoding(typeEncoding); let methodAdded = class_addMethod(originalClass, selector, trampoline, typeEncoding); if (!methodAdded) { - let msg = [NSString stringWithFormat:@"Failed to add method for selector %@ to class %@", NSStringFromSelector(selector), NSStringFromClass(originalClass)]; - ERROR_AND_RETURN(SuperBuilderErrorCodeFailedToAddMethod, msg) + if (error) { + let message = [NSString stringWithFormat:@"Failed to add method for selector %@ to class %@", NSStringFromSelector(selector), NSStringFromClass(originalClass)]; + *error = [NSError errorWithDomain:ITKSuperBuilderErrorDomain + code:SuperBuilderErrorCodeFailedToAddMethod + userInfo:@{NSLocalizedDescriptionKey: message}]; + return NO; + } } return methodAdded; } ++ (BOOL)isSuperTrampolineForClass:(Class)originalClass + selector:(SEL)selector +{ + let method = class_getInstanceMethod(originalClass, selector); + return ITKMethodIsSuperTrampoline(method); +} + +@end + // Control if the trampoline should also push/pop the floating point registers. // This is slightly slower and not needed for our simple implementation // However, even if you just use memcpy, you will want to enable this. @@ -81,12 +78,50 @@ + (BOOL)addSuperInstanceMethodToClass:(Class)originalClass selector:(SEL)selecto // One thread local per thread should be enough _Thread_local struct objc_super _threadSuperStorage; +void msgSendSuperTrampoline(void); + +#if defined (__x86_64__) +void msgSendSuperStretTrampoline(void); +#endif + +static IMP ITKGetTrampolineForTypeEncoding(__unused const char *typeEncoding) { +#if defined (__arm64__) + // arm64 doesn't use stret dispatch. Yay! + return (IMP)msgSendSuperTrampoline; +#elif defined (__x86_64__) + // On x86_64, stret dispatch is ~used whenever return type doesn't fit into two registers + // + // http://www.sealiesoftware.com/blog/archive/2008/10/30/objc_explain_objc_msgSend_stret.html + // x86_64 is more complicated, including rules for returning floating-point struct fields in FPU registers, and ppc64's rules and exceptions will make your head spin. The gory details are documented in the Mac OS X ABI Guide, though as usual if the documentation and the compiler disagree then the documentation is wrong. + NSUInteger returnTypeActualSize = 0; + NSGetSizeAndAlignment(typeEncoding, &returnTypeActualSize, NULL); + BOOL requiresStructDispatch = returnTypeActualSize > (sizeof(void *) * 2); + return requiresStructDispatch ? (IMP)msgSendSuperStretTrampoline : (IMP)msgSendSuperTrampoline; +#else + // Unknown architecture + // https://devblogs.microsoft.com/xamarin/apple-new-processor-architecture/ + // watchOS uses arm64_32 since series 4, before armv7k. watch Simulator uses i386. + // See ILP32: http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.dai0490a/ar01s01.html + return NO; +#endif +} + static BOOL ITKMethodIsSuperTrampoline(Method method) { let methodIMP = method_getImplementation(method); - return methodIMP == (IMP)msgSendSuperTrampoline || methodIMP == (IMP)msgSendSuperStretTrampoline; + + if (methodIMP == (IMP)msgSendSuperTrampoline) { + return YES; + } + + #if defined (__x86_64__) + if (methodIMP == (IMP)msgSendSuperStretTrampoline) { + return YES; + } + #endif + + return NO; } -struct objc_super *ITKReturnThreadSuper(__unsafe_unretained id obj, SEL _cmd); struct objc_super *ITKReturnThreadSuper(__unsafe_unretained id obj, SEL _cmd) { /** Assume you have a class hierarchy made of four classes `Level1` <- `Level2` <- `Level3` <- `Level4`, @@ -120,8 +155,6 @@ static BOOL ITKMethodIsSuperTrampoline(Method method) { return _super; } -@end - /** Inline assembly is used to perfectly forward all parameters to objc_msgSendSuper, while also looking up the target on-the-fly. @@ -193,9 +226,6 @@ asm volatile ( : : : "x0", "x1"); } -// arm64 doesn't use _stret variants. -void msgSendSuperStretTrampoline(void) {} - #elif defined(__x86_64__) __attribute__((__naked__)) @@ -265,7 +295,6 @@ asm volatile ( : : : "rsi", "rdi"); } - __attribute__((__naked__)) void msgSendSuperStretTrampoline(void) { asm volatile ( @@ -312,11 +341,6 @@ asm volatile ( : : : "rsi", "rdi"); } -#else -// Unknown architecture - time to write some assembly :) -void msgSendSuperTrampoline(void) {} -void msgSendSuperStretTrampoline(void) {} #endif NS_ASSUME_NONNULL_END -#endif From 11aeb3dc0062cec287a4896ccf51d8d2683dd7b2 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Mon, 31 Mar 2025 12:06:48 +0200 Subject: [PATCH 03/18] Added reference graph diagram to Hook --- Sources/InterposeKit/Hooks/Hook.swift | 36 ++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index d4d822d..29fe3ae 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -13,11 +13,39 @@ public final class Hook { build: @escaping HookBuilder ) throws { self.makeStrategy = { hook in + // Hook should never be deallocated when invoking `makeHookIMP()`, as this only + // occurs during strategy installation, which is triggered from a live hook instance. + // + // To ensure this, a strong reference cycle is intentionally created when the hook + // is applied: the strategy installs a block-based IMP (stored in `appliedHookIMP`) + // that retains a hook proxy, which in turn holds a strong reference to the hook. + // This keeps the hook alive while it is applied, allowing access to its original + // implementation. + // + // When not applied (i.e. prepared or reverted), `makeHookIMP` captures the hook + // weakly to avoid premature retention, causing the hook to be deallocated when + // the client releases it. + // + // Reference graph: + // +------------------+ + // | Client | + // +------------------+ + // | + // v + // +------------------+ +------------------+ weak + // | HookProxy |---->| Hook |< - - - - - - -+ + // +------------------+ +------------------+ | + // ^ | | + // | v | + // | +------------------+ +------------------+ + // | | HookStrategy |---->| makeHookIMP | + // | +------------------+ +------------------+ + // | | + // | v + // | +------------------+ + // +--------------| appliedHookIMP | + // +------------------+ let makeHookIMP: () -> IMP = { [weak hook] in - - // Hook should never be deallocated when invoking `makeHookIMP()`, as this only - // happens when installing implementation from within the strategy, which is - // triggered from a live hook instance. guard let hook else { Interpose.fail( """ From dfb10c6a608a2a7efa79842abcefdb0b70b5b0b5 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Mon, 31 Mar 2025 12:11:05 +0200 Subject: [PATCH 04/18] Aligned usage of `class`, clazz, klass --- Sources/ITKSuperBuilder/src/ITKSuperBuilder.m | 8 ++++---- Sources/InterposeKit/InterposeError.swift | 20 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Sources/ITKSuperBuilder/src/ITKSuperBuilder.m b/Sources/ITKSuperBuilder/src/ITKSuperBuilder.m index 5b1eeeb..3869d6e 100644 --- a/Sources/ITKSuperBuilder/src/ITKSuperBuilder.m +++ b/Sources/ITKSuperBuilder/src/ITKSuperBuilder.m @@ -138,15 +138,15 @@ static BOOL ITKMethodIsSuperTrampoline(Method method) { Looking at the method implementation we can also skip subsequent super calls. */ Class clazz = object_getClass(obj); - Class superclazz = class_getSuperclass(clazz); + Class superclass = class_getSuperclass(clazz); do { - let superclassMethod = class_getInstanceMethod(superclazz, _cmd); + let superclassMethod = class_getInstanceMethod(superclass, _cmd); let sameMethods = class_getInstanceMethod(clazz, _cmd) == superclassMethod; if (!sameMethods && !ITKMethodIsSuperTrampoline(superclassMethod)) { break; } - clazz = superclazz; - superclazz = class_getSuperclass(clazz); + clazz = superclass; + superclass = class_getSuperclass(clazz); } while (1); struct objc_super *_super = &_threadSuperStorage; diff --git a/Sources/InterposeKit/InterposeError.swift b/Sources/InterposeKit/InterposeError.swift index f305bcb..98a844b 100644 --- a/Sources/InterposeKit/InterposeError.swift +++ b/Sources/InterposeKit/InterposeError.swift @@ -76,16 +76,16 @@ extension InterposeError: Equatable { public var errorDescription: String { switch self { - case .methodNotFound(let klass, let selector): - return "Method not found: -[\(klass) \(selector)]" - case .methodNotDirectlyImplemented(let klass, let selector): - return "Method not directly implemented: -[\(klass) \(selector)]" - case .implementationNotFound(let klass, let selector): - return "Implementation not found: -[\(klass) \(selector)]" - case .revertCorrupted(let klass, let selector, let IMP): - return "Unexpected Implementation in -[\(klass) \(selector)]: \(String(describing: IMP))" - case .failedToAllocateClassPair(let klass, let subclassName): - return "Failed to allocate class pair: \(klass), \(subclassName)" + case .methodNotFound(let `class`, let selector): + return "Method not found: -[\(`class`) \(selector)]" + case .methodNotDirectlyImplemented(let `class`, let selector): + return "Method not directly implemented: -[\(`class`) \(selector)]" + case .implementationNotFound(let `class`, let selector): + return "Implementation not found: -[\(`class`) \(selector)]" + case .revertCorrupted(let `class`, let selector, let IMP): + return "Unexpected Implementation in -[\(`class`) \(selector)]: \(String(describing: IMP))" + case .failedToAllocateClassPair(let `class`, let subclassName): + return "Failed to allocate class pair: \(`class`), \(subclassName)" case .kvoDetected(let obj): return "Unable to hook object that uses Key Value Observing: \(obj)" case .objectPosingAsDifferentClass(let obj, let actualClass): From 3f9ddccadc9a63a00522bd408cca792d49d34d28 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 2 Apr 2025 10:24:52 +0200 Subject: [PATCH 05/18] =?UTF-8?q?ObjectHookStrategy:=20Don=E2=80=99t=20sto?= =?UTF-8?q?re=20dynamic=20subclass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InterposeSubclass.swift | 52 +++++++------------ .../ObjectHookStrategy.swift | 52 +++++++++---------- Sources/InterposeKit/InterposeError.swift | 5 -- 3 files changed, 43 insertions(+), 66 deletions(-) diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/InterposeSubclass.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/InterposeSubclass.swift index 3f47934..c34231c 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/InterposeSubclass.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/InterposeSubclass.swift @@ -2,42 +2,38 @@ import Foundation import ITKSuperBuilder class InterposeSubclass { - + private enum Constants { static let subclassSuffix = "InterposeKit_" } - + /// Subclass that we create on the fly - private(set) var dynamicClass: AnyClass - + //private(set) var dynamicClass: AnyClass + /// If the class has been altered (e.g. via NSKVONotifying_ KVO logic) /// then perceived and actual class don't match. /// /// Making KVO and Object-based hooking work at the same time is difficult. /// If we make a dynamic subclass over KVO, invalidating the token crashes in cache_getImp. - init(object: AnyObject) throws { - let dynamicClass: AnyClass = try { () throws -> AnyClass in - if let dynamicClass = Self.getExistingSubclass(object: object) { - return dynamicClass - } - - return try Self.createSubclass(object: object) - }() - - self.dynamicClass = dynamicClass + + static func getDynamicSubclass(for object: AnyObject) throws -> AnyClass { + if let existingSubclass = Self.getExistingSubclass(object: object) { + return existingSubclass + } else { + return try self.createSubclass(object: object) + } } - + private static func createSubclass(object: AnyObject) throws -> AnyClass { let perceivedClass: AnyClass = type(of: object) let actualClass: AnyClass = object_getClass(object)! - + let className = NSStringFromClass(perceivedClass) // Right now we are wasteful. Might be able to optimize for shared IMP? let uuid = UUID().uuidString.replacingOccurrences(of: "-", with: "") let subclassName = Constants.subclassSuffix + className + uuid - + let subclass: AnyClass? = subclassName.withCString { cString in - // swiftlint:disable:next force_cast if let existingClass = objc_getClass(cString) as! AnyClass? { return existingClass } else { @@ -47,34 +43,24 @@ class InterposeSubclass { return subclass } } - + guard let nnSubclass = subclass else { throw InterposeError.failedToAllocateClassPair(class: perceivedClass, subclassName: subclassName) } - + object_setClass(object, nnSubclass) let oldName = NSStringFromClass(class_getSuperclass(object_getClass(object)!)!) Interpose.log("Generated \(NSStringFromClass(nnSubclass)) for object (was: \(oldName))") return nnSubclass } - + /// We need to reuse a dynamic subclass if the object already has one. - private static func getExistingSubclass(object: AnyObject) -> AnyClass? { + static func getExistingSubclass(object: AnyObject) -> AnyClass? { let actualClass: AnyClass = object_getClass(object)! if NSStringFromClass(actualClass).hasPrefix(Constants.subclassSuffix) { return actualClass } return nil } - - func addSuperTrampoline(selector: Selector) { - do { - try ITKSuperBuilder.addSuperInstanceMethod(to: dynamicClass, selector: selector) - - let imp = class_getMethodImplementation(dynamicClass, selector)! - Interpose.log("Added super for -[\(dynamicClass).\(selector)]: \(imp)") - } catch { - Interpose.log("Failed to add super implementation to -[\(dynamicClass).\(selector)]: \(error)") - } - } + } diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift index 3b43636..2b0fbf3 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift @@ -1,4 +1,5 @@ import Foundation +import ITKSuperBuilder final class ObjectHookStrategy: HookStrategy { @@ -28,11 +29,6 @@ final class ObjectHookStrategy: HookStrategy { ) /// Subclass that we create on the fly - var interposeSubclass: InterposeSubclass? - - var dynamicSubclass: AnyClass { - interposeSubclass!.dynamicClass - } func validate() throws { guard class_getInstanceMethod(self.class, self.selector) != nil else { @@ -66,27 +62,33 @@ final class ObjectHookStrategy: HookStrategy { // Check if there's an existing subclass we can reuse. // Create one at runtime if there is none. - self.interposeSubclass = try InterposeSubclass(object: self.object) + let dynamicSubclass: AnyClass = try InterposeSubclass.getDynamicSubclass(for: self.object) // This function searches superclasses for implementations - let classImplementsMethod = class_implementsInstanceMethod(self.dynamicSubclass, self.selector) + let classImplementsMethod = class_implementsInstanceMethod(dynamicSubclass, self.selector) let encoding = method_getTypeEncoding(method) // If the subclass is empty, we create a super trampoline first. // If a hook already exists, we must skip this. if !classImplementsMethod { - // TODO: Make this failable - self.interposeSubclass!.addSuperTrampoline(selector: self.selector) + do { + try ITKSuperBuilder.addSuperInstanceMethod(to: dynamicSubclass, selector: self.selector) + let imp = class_getMethodImplementation(dynamicSubclass, self.selector)! + Interpose.log("Added super trampoline for -[\(dynamicSubclass) \(self.selector)]: \(imp)") + } catch { + // Interpose.log("Failed to add super implementation to -[\(dynamicClass).\(selector)]: \(error)") + throw InterposeError.unknownError(String(describing: error)) + } } // Replace IMP (by now we guarantee that it exists) - self.storedOriginalIMP = class_replaceMethod(self.dynamicSubclass, self.selector, hookIMP, encoding) + self.storedOriginalIMP = class_replaceMethod(dynamicSubclass, self.selector, hookIMP, encoding) guard self.storedOriginalIMP != nil else { // This should not happen if the class implements the method or we have installed // the super trampoline. Instead, we should make the trampoline implementation // failable. throw InterposeError.implementationNotFound( - class: self.dynamicSubclass, + class: dynamicSubclass, selector: self.selector ) } @@ -95,48 +97,42 @@ final class ObjectHookStrategy: HookStrategy { func restoreImplementation() throws { guard let hookIMP = self.appliedHookIMP else { return } + guard let originalIMP = self.storedOriginalIMP else { return } + defer { imp_removeBlock(hookIMP) self.appliedHookIMP = nil + self.storedOriginalIMP = nil } + guard let dynamicSubclass = InterposeSubclass.getExistingSubclass(object: self.object) else { return } + guard let method = class_getInstanceMethod(self.class, self.selector) else { throw InterposeError.methodNotFound(class: self.class, selector: self.selector) } - guard self.storedOriginalIMP != nil else { - // Removing methods at runtime is not supported. - // https://stackoverflow.com/questions/1315169/how-do-i-remove-instance-methods-at-runtime-in-objective-c-2-0 - // - // This codepath will be hit if the super helper is missing. - // We could recreate the whole class at runtime and rebuild all hooks, - // but that seems excessive when we have a trampoline at our disposal. - Interpose.log("Reset of -[\(self.class).\(self.selector)] not supported. No IMP") - throw InterposeError.resetUnsupported("No Original IMP found. SuperBuilder missing?") - } - - guard let currentIMP = class_getMethodImplementation(self.dynamicSubclass, self.selector) else { + guard let currentIMP = class_getMethodImplementation(dynamicSubclass, self.selector) else { throw InterposeError.unknownError("No Implementation found") } // We are the topmost hook, replace method. if currentIMP == hookIMP { - let previousIMP = class_replaceMethod(self.dynamicSubclass, self.selector, self.storedOriginalIMP!, method_getTypeEncoding(method)) + let previousIMP = class_replaceMethod(dynamicSubclass, self.selector, originalIMP, method_getTypeEncoding(method)) guard previousIMP == hookIMP else { throw InterposeError.revertCorrupted( - class: self.dynamicSubclass, + class: dynamicSubclass, selector: self.selector, imp: previousIMP ) } - Interpose.log("Restored -[\(self.class).\(self.selector)] IMP: \(self.storedOriginalIMP!)") + Interpose.log("Restored -[\(self.class).\(self.selector)] IMP: \(originalIMP)") } else { let nextHook = self._findParentHook(from: currentIMP) // Replace next's original IMP - nextHook?.originalIMP = self.storedOriginalIMP + nextHook?.originalIMP = originalIMP } - self.storedOriginalIMP = nil + // FUTURE: remove class pair! // This might fail if we get KVO observed. diff --git a/Sources/InterposeKit/InterposeError.swift b/Sources/InterposeKit/InterposeError.swift index 98a844b..5a0b36a 100644 --- a/Sources/InterposeKit/InterposeError.swift +++ b/Sources/InterposeKit/InterposeError.swift @@ -61,9 +61,6 @@ public enum InterposeError: LocalizedError { /// Use `NSClassFromString` to get the correct name. case objectPosingAsDifferentClass(AnyObject, actualClass: AnyClass) - /// Unable to remove hook. - case resetUnsupported(_ reason: String) - /// Generic failure case unknownError(_ reason: String) } @@ -90,8 +87,6 @@ extension InterposeError: Equatable { return "Unable to hook object that uses Key Value Observing: \(obj)" case .objectPosingAsDifferentClass(let obj, let actualClass): return "Unable to hook \(type(of: obj)) posing as \(NSStringFromClass(actualClass))/" - case .resetUnsupported(let reason): - return "Reset Unsupported: \(reason)" case .unknownError(let reason): return reason } From 17f9f0fd21dfdacd10dcfd958c46492d23509182 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 2 Apr 2025 11:55:55 +0200 Subject: [PATCH 06/18] Used object pointer in dynamic subclass names --- .../InterposeSubclass.swift | 80 ++++++++++++------- .../ObjectHookStrategy.swift | 6 +- .../Utilities/object_getClass.swift | 13 +++ Tests/InterposeKitTests/ObjectHookTests.swift | 52 +++++++++++- 4 files changed, 117 insertions(+), 34 deletions(-) create mode 100644 Sources/InterposeKit/Utilities/object_getClass.swift diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/InterposeSubclass.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/InterposeSubclass.swift index c34231c..00e1d77 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/InterposeSubclass.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/InterposeSubclass.swift @@ -1,37 +1,38 @@ import Foundation -import ITKSuperBuilder -class InterposeSubclass { +internal enum InterposeSubclass { - private enum Constants { - static let subclassSuffix = "InterposeKit_" + internal static func dynamicSubclass( + for object: NSObject + ) throws -> AnyClass { + // Reuse the subclass if already installed on the object. + if let installedSubclass = self.installedDynamicSubclass(for: object) { + return installedSubclass + } + + return try self.makeDynamicSubclass(for: object) } - /// Subclass that we create on the fly - //private(set) var dynamicClass: AnyClass - - /// If the class has been altered (e.g. via NSKVONotifying_ KVO logic) - /// then perceived and actual class don't match. - /// - /// Making KVO and Object-based hooking work at the same time is difficult. - /// If we make a dynamic subclass over KVO, invalidating the token crashes in cache_getImp. - - static func getDynamicSubclass(for object: AnyObject) throws -> AnyClass { - if let existingSubclass = Self.getExistingSubclass(object: object) { - return existingSubclass - } else { - return try self.createSubclass(object: object) + internal static func installedDynamicSubclass( + for object: NSObject + ) -> AnyClass? { + let actualClass: AnyClass = object_getClass(object) + if NSStringFromClass(actualClass).hasPrefix(self.namePrefix) { + return actualClass } + return nil } - private static func createSubclass(object: AnyObject) throws -> AnyClass { + private static func makeDynamicSubclass( + for object: NSObject + ) throws -> AnyClass { let perceivedClass: AnyClass = type(of: object) - let actualClass: AnyClass = object_getClass(object)! + let actualClass: AnyClass = object_getClass(object) - let className = NSStringFromClass(perceivedClass) - // Right now we are wasteful. Might be able to optimize for shared IMP? - let uuid = UUID().uuidString.replacingOccurrences(of: "-", with: "") - let subclassName = Constants.subclassSuffix + className + uuid + let subclassName = self.uniqueSubclassName( + for: object, + perceivedClass: perceivedClass + ) let subclass: AnyClass? = subclassName.withCString { cString in if let existingClass = objc_getClass(cString) as! AnyClass? { @@ -54,13 +55,30 @@ class InterposeSubclass { return nnSubclass } - /// We need to reuse a dynamic subclass if the object already has one. - static func getExistingSubclass(object: AnyObject) -> AnyClass? { - let actualClass: AnyClass = object_getClass(object)! - if NSStringFromClass(actualClass).hasPrefix(Constants.subclassSuffix) { - return actualClass - } - return nil + /// Constructs a unique subclass name for a specific object and its perceived class. + /// + /// Previously, subclass names used random UUIDs to ensure uniqueness. Since each dynamic + /// subclass is tied to a single concrete object, we now use the object’s memory address + /// instead. This eliminates randomness while still guaranteeing unique subclass names, + /// which is necessary when hooking multiple objects of the same class. + private static func uniqueSubclassName( + for object: NSObject, + perceivedClass: AnyClass + ) -> String { + let className = NSStringFromClass(perceivedClass) + let pointer = Unmanaged.passUnretained(object).toOpaque() + let objectAddress = UInt(bitPattern: pointer) + let pointerWidth = MemoryLayout.size * 2 + return "\(self.namePrefix)_\(className)_\(String(format: "%0\(pointerWidth)llx", objectAddress))" } + /// The prefix to use in names of dynamically created subclasses. + private static let namePrefix = "InterposeKit" + } + +/// If the class has been altered (e.g. via NSKVONotifying_ KVO logic) +/// then perceived and actual class don't match. +/// +/// Making KVO and Object-based hooking work at the same time is difficult. +/// If we make a dynamic subclass over KVO, invalidating the token crashes in cache_getImp. diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift index 2b0fbf3..88e2853 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift @@ -62,7 +62,7 @@ final class ObjectHookStrategy: HookStrategy { // Check if there's an existing subclass we can reuse. // Create one at runtime if there is none. - let dynamicSubclass: AnyClass = try InterposeSubclass.getDynamicSubclass(for: self.object) + let dynamicSubclass: AnyClass = try InterposeSubclass.dynamicSubclass(for: self.object) // This function searches superclasses for implementations let classImplementsMethod = class_implementsInstanceMethod(dynamicSubclass, self.selector) @@ -105,7 +105,9 @@ final class ObjectHookStrategy: HookStrategy { self.storedOriginalIMP = nil } - guard let dynamicSubclass = InterposeSubclass.getExistingSubclass(object: self.object) else { return } + guard let dynamicSubclass = InterposeSubclass.installedDynamicSubclass( + for: self.object + ) else { return } guard let method = class_getInstanceMethod(self.class, self.selector) else { throw InterposeError.methodNotFound(class: self.class, selector: self.selector) diff --git a/Sources/InterposeKit/Utilities/object_getClass.swift b/Sources/InterposeKit/Utilities/object_getClass.swift new file mode 100644 index 0000000..cf7ae7c --- /dev/null +++ b/Sources/InterposeKit/Utilities/object_getClass.swift @@ -0,0 +1,13 @@ +import ObjectiveC + +/// Returns the class of an object. +/// +/// - Parameter object: A non-nil object to inspect. +/// - Returns: The class of which `object` is an instance. +internal func object_getClass(_ object: AnyObject) -> AnyClass { + if let `class` = ObjectiveC.object_getClass(object as Any?) { + return `class` + } else { + fatalError("Expected object_getClass(…) to return a class for non-nil object \(object).") + } +} diff --git a/Tests/InterposeKitTests/ObjectHookTests.swift b/Tests/InterposeKitTests/ObjectHookTests.swift index 7af60d6..4ff21cb 100644 --- a/Tests/InterposeKitTests/ObjectHookTests.swift +++ b/Tests/InterposeKitTests/ObjectHookTests.swift @@ -1,4 +1,4 @@ -import InterposeKit +@testable import InterposeKit import XCTest fileprivate class ExampleClass: NSObject { @@ -82,6 +82,56 @@ final class ObjectHookTests: XCTestCase { XCTAssertEqual(object.arrayValue, ["base"]) } + func testHookOnMultipleObjects() throws { + let object1 = ExampleClass() + let object2 = ExampleClass() + + XCTAssertEqual(object1.arrayValue, ["base"]) + XCTAssertEqual(object2.arrayValue, ["base"]) + + XCTAssertEqual( + NSStringFromClass(object_getClass(object1)), + NSStringFromClass(object_getClass(object2)) + ) + + let hook1 = try object1.applyHook( + for: #selector(getter: ExampleClass.arrayValue), + methodSignature: (@convention(c) (NSObject, Selector) -> [String]).self, + hookSignature: (@convention(block) (NSObject) -> [String]).self + ) { hook in + return { `self` in + return hook.original(self, hook.selector) + ["hook1"] + } + } + XCTAssertEqual(object1.arrayValue, ["base", "hook1"]) + XCTAssertEqual(object2.arrayValue, ["base"]) + + let hook2 = try object2.applyHook( + for: #selector(getter: ExampleClass.arrayValue), + methodSignature: (@convention(c) (NSObject, Selector) -> [String]).self, + hookSignature: (@convention(block) (NSObject) -> [String]).self + ) { hook in + return { `self` in + return hook.original(self, hook.selector) + ["hook2"] + } + } + XCTAssertEqual(object1.arrayValue, ["base", "hook1"]) + XCTAssertEqual(object2.arrayValue, ["base", "hook2"]) + + XCTAssertNotEqual( + NSStringFromClass(object_getClass(object1)), + NSStringFromClass(object_getClass(object2)) + ) + + try hook1.revert() + XCTAssertEqual(object1.arrayValue, ["base"]) + XCTAssertEqual(object2.arrayValue, ["base", "hook2"]) + + try hook2.revert() + XCTAssertEqual(object1.arrayValue, ["base"]) + XCTAssertEqual(object2.arrayValue, ["base"]) + } + // Hooking fails on an object that has KVO activated. func testKVO_observationBeforeHooking() throws { let object = ExampleClass() From 55ef948654482e0a23d3bdea4beb99c845966e55 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 3 Apr 2025 10:01:54 +0200 Subject: [PATCH 07/18] More refactoring --- .../ObjectHookStrategy.swift | 4 +- ...lass.swift => ObjectSubclassManager.swift} | 76 +++++++++---------- 2 files changed, 39 insertions(+), 41 deletions(-) rename Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/{InterposeSubclass.swift => ObjectSubclassManager.swift} (54%) diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift index 88e2853..f146a6a 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift @@ -62,7 +62,7 @@ final class ObjectHookStrategy: HookStrategy { // Check if there's an existing subclass we can reuse. // Create one at runtime if there is none. - let dynamicSubclass: AnyClass = try InterposeSubclass.dynamicSubclass(for: self.object) + let dynamicSubclass: AnyClass = try ObjectSubclassManager.ensureSubclassInstalled(for: self.object) // This function searches superclasses for implementations let classImplementsMethod = class_implementsInstanceMethod(dynamicSubclass, self.selector) @@ -105,7 +105,7 @@ final class ObjectHookStrategy: HookStrategy { self.storedOriginalIMP = nil } - guard let dynamicSubclass = InterposeSubclass.installedDynamicSubclass( + guard let dynamicSubclass = ObjectSubclassManager.installedSubclass( for: self.object ) else { return } diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/InterposeSubclass.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectSubclassManager.swift similarity index 54% rename from Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/InterposeSubclass.swift rename to Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectSubclassManager.swift index 00e1d77..ae2cad5 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/InterposeSubclass.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectSubclassManager.swift @@ -1,58 +1,62 @@ import Foundation -internal enum InterposeSubclass { +internal enum ObjectSubclassManager { - internal static func dynamicSubclass( + internal static func installedSubclass( for object: NSObject - ) throws -> AnyClass { - // Reuse the subclass if already installed on the object. - if let installedSubclass = self.installedDynamicSubclass(for: object) { - return installedSubclass - } - - return try self.makeDynamicSubclass(for: object) + ) -> AnyClass? { + let actualClass: AnyClass = object_getClass(object) + let hasPrefix = NSStringFromClass(actualClass).hasPrefix(self.namePrefix) + return hasPrefix ? actualClass : nil } - internal static func installedDynamicSubclass( + internal static func ensureSubclassInstalled( for object: NSObject - ) -> AnyClass? { - let actualClass: AnyClass = object_getClass(object) - if NSStringFromClass(actualClass).hasPrefix(self.namePrefix) { - return actualClass + ) throws -> AnyClass { + // If there is a dynamic subclass already installed on the object, reuse it straightaway. + if let installedSubclass = self.installedSubclass(for: object) { + return installedSubclass } - return nil + + let subclass: AnyClass = try self.makeSubclass(for: object) + object_setClass(object, subclass) + + let oldName = NSStringFromClass(class_getSuperclass(object_getClass(object)!)!) + Interpose.log("Generated \(NSStringFromClass(subclass)) for object (was: \(oldName))") + + return subclass } - private static func makeDynamicSubclass( + private static func makeSubclass( for object: NSObject ) throws -> AnyClass { - let perceivedClass: AnyClass = type(of: object) let actualClass: AnyClass = object_getClass(object) + let perceivedClass: AnyClass = type(of: object) let subclassName = self.uniqueSubclassName( for: object, perceivedClass: perceivedClass ) - let subclass: AnyClass? = subclassName.withCString { cString in - if let existingClass = objc_getClass(cString) as! AnyClass? { + return try subclassName.withCString { cString in + // ??? + if let existingClass = objc_getClass(cString) as? AnyClass { + print("Existing", subclassName) return existingClass - } else { - guard let subclass: AnyClass = objc_allocateClassPair(actualClass, cString, 0) else { return nil } - class_setPerceivedClass(for: subclass, to: perceivedClass) - objc_registerClassPair(subclass) - return subclass } + + guard let subclass: AnyClass = objc_allocateClassPair(actualClass, cString, 0) else { + throw InterposeError.failedToAllocateClassPair( + class: perceivedClass, + subclassName: subclassName + ) + } + + class_setPerceivedClass(for: subclass, to: perceivedClass) + objc_registerClassPair(subclass) + + return subclass } - - guard let nnSubclass = subclass else { - throw InterposeError.failedToAllocateClassPair(class: perceivedClass, subclassName: subclassName) - } - - object_setClass(object, nnSubclass) - let oldName = NSStringFromClass(class_getSuperclass(object_getClass(object)!)!) - Interpose.log("Generated \(NSStringFromClass(nnSubclass)) for object (was: \(oldName))") - return nnSubclass } /// Constructs a unique subclass name for a specific object and its perceived class. @@ -76,9 +80,3 @@ internal enum InterposeSubclass { private static let namePrefix = "InterposeKit" } - -/// If the class has been altered (e.g. via NSKVONotifying_ KVO logic) -/// then perceived and actual class don't match. -/// -/// Making KVO and Object-based hooking work at the same time is difficult. -/// If we make a dynamic subclass over KVO, invalidating the token crashes in cache_getImp. From 126d481827d448cfda487b6e0cdf6acfd06dc974 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 3 Apr 2025 10:23:15 +0200 Subject: [PATCH 08/18] Subclass names now use global counter instead of memory address --- .../ObjectSubclassManager.swift | 71 +++++++++++++------ 1 file changed, 48 insertions(+), 23 deletions(-) diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectSubclassManager.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectSubclassManager.swift index ae2cad5..f2e62e3 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectSubclassManager.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectSubclassManager.swift @@ -2,6 +2,10 @@ import Foundation internal enum ObjectSubclassManager { + // ============================================================================ // + // MARK: Getting Installed Subclass + // ============================================================================ // + internal static func installedSubclass( for object: NSObject ) -> AnyClass? { @@ -10,6 +14,10 @@ internal enum ObjectSubclassManager { return hasPrefix ? actualClass : nil } + // ============================================================================ // + // MARK: Installing & Uninstalling + // ============================================================================ // + internal static func ensureSubclassInstalled( for object: NSObject ) throws -> AnyClass { @@ -18,33 +26,38 @@ internal enum ObjectSubclassManager { return installedSubclass } + // Otherwise, create a dynamic subclass by generating a unique name and registering it + // with the runtime. let subclass: AnyClass = try self.makeSubclass(for: object) + + // Then, set the created class on the object. object_setClass(object, subclass) - let oldName = NSStringFromClass(class_getSuperclass(object_getClass(object)!)!) + let oldName = NSStringFromClass(class_getSuperclass(object_getClass(object))!) Interpose.log("Generated \(NSStringFromClass(subclass)) for object (was: \(oldName))") return subclass } + internal static func uninstallSubclass( + for object: NSObject + ) { + fatalError("Not yet implemented") + } + + // ============================================================================ // + // MARK: Subclass Generation + // ============================================================================ // + private static func makeSubclass( for object: NSObject ) throws -> AnyClass { let actualClass: AnyClass = object_getClass(object) let perceivedClass: AnyClass = type(of: object) - let subclassName = self.uniqueSubclassName( - for: object, - perceivedClass: perceivedClass - ) + let subclassName = self.uniqueSubclassName(for: perceivedClass) return try subclassName.withCString { cString in - // ??? - if let existingClass = objc_getClass(cString) as? AnyClass { - print("Existing", subclassName) - return existingClass - } - guard let subclass: AnyClass = objc_allocateClassPair(actualClass, cString, 0) else { throw InterposeError.failedToAllocateClassPair( class: perceivedClass, @@ -59,24 +72,36 @@ internal enum ObjectSubclassManager { } } - /// Constructs a unique subclass name for a specific object and its perceived class. + /// Constructs a unique subclass name for the given perceived class. + /// + /// Subclass names must be globally unique to avoid registration conflicts. Earlier versions + /// used random UUIDs, which guaranteed uniqueness but resulted in long and noisy names. + /// We then considered using the object’s memory address, but since addresses can be reused + /// during the lifetime of a process, this led to potential conflicts and flaky test behavior. /// - /// Previously, subclass names used random UUIDs to ensure uniqueness. Since each dynamic - /// subclass is tied to a single concrete object, we now use the object’s memory address - /// instead. This eliminates randomness while still guaranteeing unique subclass names, - /// which is necessary when hooking multiple objects of the same class. + /// The final approach uses a global incrementing counter to ensure uniqueness without relying + /// on randomness or memory layout. This results in shorter, more readable names that are safe + /// across repeated test runs and stable in production. private static func uniqueSubclassName( - for object: NSObject, - perceivedClass: AnyClass + for perceivedClass: AnyClass ) -> String { let className = NSStringFromClass(perceivedClass) - let pointer = Unmanaged.passUnretained(object).toOpaque() - let objectAddress = UInt(bitPattern: pointer) - let pointerWidth = MemoryLayout.size * 2 - return "\(self.namePrefix)_\(className)_\(String(format: "%0\(pointerWidth)llx", objectAddress))" + + let counterSuffix: String = self.subclassCounterQueue.sync { + self.subclassCounter &+= 1 + return String(format: "%04llx", self.subclassCounter) + } + + return "\(self.namePrefix)_\(className)_\(counterSuffix)" } - /// The prefix to use in names of dynamically created subclasses. + /// The prefix used for all dynamically created subclass names. private static let namePrefix = "InterposeKit" + /// A global counter for generating unique subclass name suffixes. + private static var subclassCounter: UInt64 = 0 + + /// A serial queue to ensure thread-safe access to `subclassCounter`. + private static let subclassCounterQueue = DispatchQueue(label: "com.steipete.InterposeKit.subclassCounter") + } From 511a59fb4b09d62bf54877a7a66e03e3b0cb7247 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 3 Apr 2025 10:32:30 +0200 Subject: [PATCH 09/18] Improved test robustness for multiple executions --- Tests/InterposeKitTests/UtilitiesTests.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Tests/InterposeKitTests/UtilitiesTests.swift b/Tests/InterposeKitTests/UtilitiesTests.swift index ca85722..1fcaae1 100644 --- a/Tests/InterposeKitTests/UtilitiesTests.swift +++ b/Tests/InterposeKitTests/UtilitiesTests.swift @@ -20,7 +20,13 @@ extension NSObject { final class UtilitiesTests: XCTestCase { - func test_setPerceivedClass() { + static var hasRunTestSetPerceivedClass = false + + func test_setPerceivedClass() throws { + // Runs only once to avoid leaking class swizzling across test runs. + try XCTSkipIf(Self.hasRunTestSetPerceivedClass, "Class override already applied.") + Self.hasRunTestSetPerceivedClass = true + let object = RealClass() XCTAssertTrue(object.objcClass === RealClass.self) From 48ee1576075d384e20fa995ea89f62f081f7e478 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 3 Apr 2025 10:52:58 +0200 Subject: [PATCH 10/18] Improved logging --- Sources/InterposeKit/Hooks/Hook.swift | 39 ++++++++++--------- .../ClassHookStrategy/ClassHookStrategy.swift | 4 +- .../ObjectHookStrategy.swift | 6 +-- .../ObjectSubclassManager.swift | 20 +++++++++- Sources/InterposeKit/Interpose.swift | 12 ++++-- 5 files changed, 52 insertions(+), 29 deletions(-) diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index 29fe3ae..5fb43e7 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -177,24 +177,27 @@ public final class Hook { // ============================================================================ // deinit { - var logComponents = [String]() - - switch self.state { - case .pending: - logComponents.append("Releasing") - case .active: - logComponents.append("Keeping") - case .failed: - logComponents.append("Leaking") - } - - logComponents.append("-[\(self.class) \(self.selector)]") - - if let hookIMP = self.strategy.appliedHookIMP { - logComponents.append("IMP: \(hookIMP)") - } - - Interpose.log(logComponents.joined(separator: " ")) + Interpose.log({ + var components = [String]() + + switch self.state { + case .pending: + components.append("Releasing") + case .active: + components.append("Keeping") + case .failed: + components.append("Leaking") + } + + components.append("hook for") + components.append("-[\(self.class) \(self.selector)]") + + if let hookIMP = self.strategy.appliedHookIMP { + components.append("IMP: \(hookIMP)") + } + + return components.joined(separator: " ") + }()) } } diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift index ee53f49..be8727b 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift @@ -81,7 +81,7 @@ internal final class ClassHookStrategy: HookStrategy { self.appliedHookIMP = hookIMP self.storedOriginalIMP = originalIMP - Interpose.log("Swizzled -[\(self.class) \(self.selector)] IMP: \(originalIMP) -> \(hookIMP)") + Interpose.log("Replaced implementation for -[\(self.class) \(self.selector)] IMP: \(originalIMP) -> \(hookIMP)") } internal func restoreImplementation() throws { @@ -116,7 +116,7 @@ internal final class ClassHookStrategy: HookStrategy { ) } - Interpose.log("Restored -[\(self.class) \(self.selector)] IMP: \(originalIMP)") + Interpose.log("Restored implementation for -[\(self.class) \(self.selector)] IMP: \(originalIMP)") } } diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift index f146a6a..21debc3 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift @@ -74,7 +74,7 @@ final class ObjectHookStrategy: HookStrategy { do { try ITKSuperBuilder.addSuperInstanceMethod(to: dynamicSubclass, selector: self.selector) let imp = class_getMethodImplementation(dynamicSubclass, self.selector)! - Interpose.log("Added super trampoline for -[\(dynamicSubclass) \(self.selector)]: \(imp)") + Interpose.log("Added super trampoline for -[\(dynamicSubclass) \(self.selector)] IMP: \(imp)") } catch { // Interpose.log("Failed to add super implementation to -[\(dynamicClass).\(selector)]: \(error)") throw InterposeError.unknownError(String(describing: error)) @@ -92,7 +92,7 @@ final class ObjectHookStrategy: HookStrategy { selector: self.selector ) } - Interpose.log("Added -[\(self.class).\(self.selector)] IMP: \(self.storedOriginalIMP!) -> \(hookIMP)") + Interpose.log("Replaced implementation for -[\(self.class) \(self.selector)] IMP: \(self.storedOriginalIMP!) -> \(hookIMP)") } func restoreImplementation() throws { @@ -127,7 +127,7 @@ final class ObjectHookStrategy: HookStrategy { imp: previousIMP ) } - Interpose.log("Restored -[\(self.class).\(self.selector)] IMP: \(originalIMP)") + Interpose.log("Restored implementation for -[\(self.class) \(self.selector)] IMP: \(originalIMP)") } else { let nextHook = self._findParentHook(from: currentIMP) // Replace next's original IMP diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectSubclassManager.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectSubclassManager.swift index f2e62e3..45a538b 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectSubclassManager.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectSubclassManager.swift @@ -23,6 +23,12 @@ internal enum ObjectSubclassManager { ) throws -> AnyClass { // If there is a dynamic subclass already installed on the object, reuse it straightaway. if let installedSubclass = self.installedSubclass(for: object) { + Interpose.log({ + let subclassName = NSStringFromClass(installedSubclass) + let objectAddress = String(format: "%p", object) + return "Reused subclass: \(subclassName) for object \(objectAddress)" + }()) + return installedSubclass } @@ -33,8 +39,18 @@ internal enum ObjectSubclassManager { // Then, set the created class on the object. object_setClass(object, subclass) - let oldName = NSStringFromClass(class_getSuperclass(object_getClass(object))!) - Interpose.log("Generated \(NSStringFromClass(subclass)) for object (was: \(oldName))") + Interpose.log({ + let subclassName = NSStringFromClass(subclass) + let objectAddress = String(format: "%p", object) + var message = "Created subclass: \(subclassName) for object \(objectAddress)" + + if let superclass = class_getSuperclass(subclass) { + let superclassName = NSStringFromClass(superclass) + message += " (was: \(superclassName))" + } + + return message + }()) return subclass } diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index f4eefe0..fa6f298 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -88,14 +88,18 @@ public enum Interpose { /// The flag indicating whether logging is enabled. public static var isLoggingEnabled = false - internal static func log(_ message: String) { + internal static func log( + _ message: @autoclosure () -> String + ) { if self.isLoggingEnabled { - print("[InterposeKit] \(message)") + print("[InterposeKit] \(message())") } } - internal static func fail(_ message: String) -> Never { - fatalError("[InterposeKit] \(message)") + internal static func fail( + _ message: @autoclosure () -> String + ) -> Never { + fatalError("[InterposeKit] \(message())") } } From 9cf2ac32c565f83dd3625a1b6491988e32436f4d Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 3 Apr 2025 11:10:44 +0200 Subject: [PATCH 11/18] More refactoring in ObjectSubclassManager --- .../ObjectSubclassManager.swift | 19 ++++++++++-------- Sources/InterposeKit/InterposeError.swift | 20 ++++++++++++++----- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectSubclassManager.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectSubclassManager.swift index 45a538b..4cd3fd9 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectSubclassManager.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectSubclassManager.swift @@ -36,17 +36,16 @@ internal enum ObjectSubclassManager { // with the runtime. let subclass: AnyClass = try self.makeSubclass(for: object) - // Then, set the created class on the object. - object_setClass(object, subclass) + // Finally, set the created class on the object. + let previousClass: AnyClass? = object_setClass(object, subclass) Interpose.log({ let subclassName = NSStringFromClass(subclass) let objectAddress = String(format: "%p", object) var message = "Created subclass: \(subclassName) for object \(objectAddress)" - if let superclass = class_getSuperclass(subclass) { - let superclassName = NSStringFromClass(superclass) - message += " (was: \(superclassName))" + if let previousClass { + message += " (previously: \(NSStringFromClass(previousClass)))" } return message @@ -74,14 +73,18 @@ internal enum ObjectSubclassManager { let subclassName = self.uniqueSubclassName(for: perceivedClass) return try subclassName.withCString { cString in + // Attempt to allocate a new subclass that inherits from the object’s actual class. guard let subclass: AnyClass = objc_allocateClassPair(actualClass, cString, 0) else { - throw InterposeError.failedToAllocateClassPair( - class: perceivedClass, - subclassName: subclassName + throw InterposeError.subclassCreationFailed( + subclassName: subclassName, + object: object ) } + // Set the perceived class to make the runtime report the original type. class_setPerceivedClass(for: subclass, to: perceivedClass) + + // Register the subclass with the runtime. objc_registerClassPair(subclass) return subclass diff --git a/Sources/InterposeKit/InterposeError.swift b/Sources/InterposeKit/InterposeError.swift index 5a0b36a..007bc65 100644 --- a/Sources/InterposeKit/InterposeError.swift +++ b/Sources/InterposeKit/InterposeError.swift @@ -44,9 +44,19 @@ public enum InterposeError: LocalizedError { selector: Selector, imp: IMP? ) - - /// Unable to register subclass for object-based interposing. - case failedToAllocateClassPair(class: AnyClass, subclassName: String) + + /// Failed to create a dynamic subclass for the given object. + /// + /// This can occur if the desired subclass name is already in use. While InterposeKit + /// generates globally unique subclass names using an internal counter, a name collision may + /// still happen if another system has registered a class with the same name earlier during + /// the process lifetime. + case subclassCreationFailed( + subclassName: String, + object: NSObject + ) + + // --- /// Object-based hooking does not work if an object is using KVO. /// The KVO mechanism also uses subclasses created at runtime but doesn't check for additional overrides. @@ -81,8 +91,8 @@ extension InterposeError: Equatable { return "Implementation not found: -[\(`class`) \(selector)]" case .revertCorrupted(let `class`, let selector, let IMP): return "Unexpected Implementation in -[\(`class`) \(selector)]: \(String(describing: IMP))" - case .failedToAllocateClassPair(let `class`, let subclassName): - return "Failed to allocate class pair: \(`class`), \(subclassName)" + case .subclassCreationFailed(let subclassName, let object): + return "Failed to allocate class pair: \(object), \(subclassName)" case .kvoDetected(let obj): return "Unable to hook object that uses Key Value Observing: \(obj)" case .objectPosingAsDifferentClass(let obj, let actualClass): From 5547ab66974756a37aa5a62be45307ec35f38ecc Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 3 Apr 2025 12:00:04 +0200 Subject: [PATCH 12/18] Improved error cases for dynamic subclasses --- .../ObjectHookStrategy.swift | 31 ++++++++-------- .../ObjectSubclassManager.swift | 12 +++++- Sources/InterposeKit/InterposeError.swift | 37 ++++++++++++------- Tests/InterposeKitTests/ObjectHookTests.swift | 2 +- 4 files changed, 50 insertions(+), 32 deletions(-) diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift index 21debc3..7bd4020 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift @@ -32,14 +32,26 @@ final class ObjectHookStrategy: HookStrategy { func validate() throws { guard class_getInstanceMethod(self.class, self.selector) != nil else { - throw InterposeError.methodNotFound(class: self.class, selector: self.selector) + throw InterposeError.methodNotFound( + class: self.class, + selector: self.selector + ) } - if let _ = checkObjectPosingAsDifferentClass(self.object) { + let perceivedClass: AnyClass = type(of: self.object) + let actualClass: AnyClass = object_getClass(self.object) + + if perceivedClass != actualClass { if object_isKVOActive(self.object) { - throw InterposeError.kvoDetected(object) + throw InterposeError.kvoDetected(object: self.object) + } + + if !ObjectSubclassManager.hasInstalledSubclass(self.object) { + throw InterposeError.unexpectedDynamicSubclass( + object: self.object, + actualClass: actualClass + ) } - // TODO: Handle the case where the object is posing as different class but not the interpose subclass } } @@ -144,17 +156,6 @@ final class ObjectHookStrategy: HookStrategy { // self.dynamicSubclass = nil } - // Checks if a object is posing as a different class - // via implementing 'class' and returning something else. - private func checkObjectPosingAsDifferentClass(_ object: AnyObject) -> AnyClass? { - let perceivedClass: AnyClass = type(of: object) - let actualClass: AnyClass = object_getClass(object)! - if actualClass != perceivedClass { - return actualClass - } - return nil - } - /// Traverses the object hook chain to find the handle to the parent of this hook, starting /// from the topmost IMP for the hooked method. /// diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectSubclassManager.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectSubclassManager.swift index 4cd3fd9..fe24ba3 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectSubclassManager.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectSubclassManager.swift @@ -10,8 +10,16 @@ internal enum ObjectSubclassManager { for object: NSObject ) -> AnyClass? { let actualClass: AnyClass = object_getClass(object) - let hasPrefix = NSStringFromClass(actualClass).hasPrefix(self.namePrefix) - return hasPrefix ? actualClass : nil + return self.isDynamicSubclass(actualClass) ? actualClass : nil + } + + internal static func hasInstalledSubclass(_ object: NSObject) -> Bool { + let actualClass: AnyClass = object_getClass(object) + return self.isDynamicSubclass(actualClass) + } + + private static func isDynamicSubclass(_ class: AnyClass) -> Bool { + NSStringFromClass(`class`).hasPrefix(self.namePrefix) } // ============================================================================ // diff --git a/Sources/InterposeKit/InterposeError.swift b/Sources/InterposeKit/InterposeError.swift index 007bc65..ead8b4f 100644 --- a/Sources/InterposeKit/InterposeError.swift +++ b/Sources/InterposeKit/InterposeError.swift @@ -56,20 +56,29 @@ public enum InterposeError: LocalizedError { object: NSObject ) - // --- - - /// Object-based hooking does not work if an object is using KVO. - /// The KVO mechanism also uses subclasses created at runtime but doesn't check for additional overrides. - /// Adding a hook eventually crashes the KVO management code so we reject hooking altogether in this case. - case kvoDetected(AnyObject) - - /// Object is lying about it's actual class metadata. - /// This usually happens when other swizzling libraries (like Aspects) also interfere with a class. - /// While this might just work, it's not worth risking a crash, so similar to KVO this case is rejected. + /// Detected Key-Value Observing on the object while applying or reverting a hook. /// - /// @note Printing classes in Swift uses the class posing mechanism. - /// Use `NSClassFromString` to get the correct name. - case objectPosingAsDifferentClass(AnyObject, actualClass: AnyClass) + /// The KVO mechanism installs its own dynamic subclass at runtime but does not support + /// additional method overrides. Applying or reverting hooks on an object under KVO can lead + /// to crashes in the Objective-C runtime, so such operations are explicitly disallowed. + /// + /// It is safe to start observing an object *after* it has been hooked, but not the other way + /// around. Once KVO is active, reverting an existing hook is also considered unsafe. + case kvoDetected(object: NSObject) + + /// The object uses a dynamic subclass that was not installed by InterposeKit. + /// + /// This typically indicates interference from another runtime system, such as method + /// swizzling libraries (like [Aspects](https://github.com/steipete/Aspects)). Similar to KVO, + /// such subclasses bypass normal safety checks. Hooking is disallowed in this case to + /// avoid crashes. + /// + /// - Note: Use `NSStringFromClass` to print class names accurately. Swift’s default + /// formatting may reflect the perceived, not the actual runtime class. + case unexpectedDynamicSubclass( + object: NSObject, + actualClass: AnyClass + ) /// Generic failure case unknownError(_ reason: String) @@ -95,7 +104,7 @@ extension InterposeError: Equatable { return "Failed to allocate class pair: \(object), \(subclassName)" case .kvoDetected(let obj): return "Unable to hook object that uses Key Value Observing: \(obj)" - case .objectPosingAsDifferentClass(let obj, let actualClass): + case .unexpectedDynamicSubclass(let obj, let actualClass): return "Unable to hook \(type(of: obj)) posing as \(NSStringFromClass(actualClass))/" case .unknownError(let reason): return reason diff --git a/Tests/InterposeKitTests/ObjectHookTests.swift b/Tests/InterposeKitTests/ObjectHookTests.swift index 4ff21cb..85bd6bb 100644 --- a/Tests/InterposeKitTests/ObjectHookTests.swift +++ b/Tests/InterposeKitTests/ObjectHookTests.swift @@ -158,7 +158,7 @@ final class ObjectHookTests: XCTestCase { hook.original(self, hook.selector) + 1 } }, - expected: InterposeError.kvoDetected(object) + expected: InterposeError.kvoDetected(object: object) ) XCTAssertEqual(object.intValue, 2) From bf914008cfbc7716421e78d47f3bf0f8c34b87e6 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 3 Apr 2025 12:07:12 +0200 Subject: [PATCH 13/18] =?UTF-8?q?Polished=20InterposeError=E2=80=99s=20con?= =?UTF-8?q?formance=20to=20Equatable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/InterposeKit/InterposeError.swift | 97 ++++++++++++++++------- 1 file changed, 67 insertions(+), 30 deletions(-) diff --git a/Sources/InterposeKit/InterposeError.swift b/Sources/InterposeKit/InterposeError.swift index ead8b4f..8027dda 100644 --- a/Sources/InterposeKit/InterposeError.swift +++ b/Sources/InterposeKit/InterposeError.swift @@ -1,6 +1,6 @@ -import Foundation +import ObjectiveC -public enum InterposeError: LocalizedError { +public enum InterposeError: Error { /// No instance method found for the selector on the specified class. /// @@ -85,34 +85,71 @@ public enum InterposeError: LocalizedError { } extension InterposeError: Equatable { - // Lazy equating via string compare - public static func == (lhs: InterposeError, rhs: InterposeError) -> Bool { - return lhs.errorDescription == rhs.errorDescription - } - - public var errorDescription: String { - switch self { - case .methodNotFound(let `class`, let selector): - return "Method not found: -[\(`class`) \(selector)]" - case .methodNotDirectlyImplemented(let `class`, let selector): - return "Method not directly implemented: -[\(`class`) \(selector)]" - case .implementationNotFound(let `class`, let selector): - return "Implementation not found: -[\(`class`) \(selector)]" - case .revertCorrupted(let `class`, let selector, let IMP): - return "Unexpected Implementation in -[\(`class`) \(selector)]: \(String(describing: IMP))" - case .subclassCreationFailed(let subclassName, let object): - return "Failed to allocate class pair: \(object), \(subclassName)" - case .kvoDetected(let obj): - return "Unable to hook object that uses Key Value Observing: \(obj)" - case .unexpectedDynamicSubclass(let obj, let actualClass): - return "Unable to hook \(type(of: obj)) posing as \(NSStringFromClass(actualClass))/" - case .unknownError(let reason): - return reason + public static func == (lhs: Self, rhs: Self) -> Bool { + switch lhs { + case let .methodNotFound(lhsClass, lhsSelector): + switch rhs { + case let .methodNotFound(rhsClass, rhsSelector): + return lhsClass == rhsClass && lhsSelector == rhsSelector + default: + return false + } + + case let .methodNotDirectlyImplemented(lhsClass, lhsSelector): + switch rhs { + case let .methodNotDirectlyImplemented(rhsClass, rhsSelector): + return lhsClass == rhsClass && lhsSelector == rhsSelector + default: + return false + } + + case let .implementationNotFound(lhsClass, lhsSelector): + switch rhs { + case let .implementationNotFound(rhsClass, rhsSelector): + return lhsClass == rhsClass && lhsSelector == rhsSelector + default: + return false + } + + case let .revertCorrupted(lhsClass, lhsSelector, lhsIMP): + switch rhs { + case let .revertCorrupted(rhsClass, rhsSelector, rhsIMP): + return lhsClass == rhsClass && lhsSelector == rhsSelector && lhsIMP == rhsIMP + default: + return false + } + + case let .subclassCreationFailed(lhsName, lhsObject): + switch rhs { + case let .subclassCreationFailed(rhsName, rhsObject): + return lhsName == rhsName && lhsObject === rhsObject + default: + return false + } + + case let .kvoDetected(lhsObject): + switch rhs { + case let .kvoDetected(rhsObject): + return lhsObject === rhsObject + default: + return false + } + + case let .unexpectedDynamicSubclass(lhsObject, lhsClass): + switch rhs { + case let .unexpectedDynamicSubclass(rhsObject, rhsClass): + return lhsObject === rhsObject && lhsClass == rhsClass + default: + return false + } + + case let .unknownError(lhsReason): + switch rhs { + case let .unknownError(rhsReason): + return lhsReason == rhsReason + default: + return false + } } } - - @discardableResult func log() -> InterposeError { - Interpose.log(self.errorDescription) - return self - } } From e52c63dd7dd4c43ff7022eca3f1bfd7c91e38adb Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 3 Apr 2025 14:52:22 +0200 Subject: [PATCH 14/18] Refactoring in ObjectHookStrategy 1/ --- .../ClassHookStrategy/ClassHookStrategy.swift | 3 +- .../ObjectHookStrategy.swift | 117 ++++++++++++------ Sources/InterposeKit/InterposeError.swift | 27 +++- 3 files changed, 102 insertions(+), 45 deletions(-) diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift index be8727b..e30b71a 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift @@ -1,4 +1,4 @@ -import Foundation +import ObjectiveC internal final class ClassHookStrategy: HookStrategy { @@ -23,6 +23,7 @@ internal final class ClassHookStrategy: HookStrategy { internal let `class`: AnyClass internal var scope: HookScope { .class } internal let selector: Selector + private let makeHookIMP: () -> IMP // ============================================================================ // diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift index 7bd4020..d0407a9 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift @@ -1,9 +1,13 @@ -import Foundation import ITKSuperBuilder +import ObjectiveC -final class ObjectHookStrategy: HookStrategy { +internal final class ObjectHookStrategy: HookStrategy { - init( + // ============================================================================ // + // MARK: Initialization + // ============================================================================ // + + internal init( object: NSObject, selector: Selector, makeHookIMP: @escaping () -> IMP @@ -14,23 +18,34 @@ final class ObjectHookStrategy: HookStrategy { self.makeHookIMP = makeHookIMP } - let `class`: AnyClass - let object: NSObject - var scope: HookScope { .object(self.object) } - let selector: Selector + // ============================================================================ // + // MARK: Configuration + // ============================================================================ // + + internal let `class`: AnyClass + internal let object: NSObject + internal var scope: HookScope { .object(self.object) } + internal let selector: Selector private let makeHookIMP: () -> IMP - private(set) var appliedHookIMP: IMP? - private(set) var storedOriginalIMP: IMP? + + // ============================================================================ // + // MARK: Implementations & Handle + // ============================================================================ // + + private(set) internal var appliedHookIMP: IMP? + private(set) internal var storedOriginalIMP: IMP? private lazy var handle = ObjectHookHandle( getOriginalIMP: { [weak self] in self?.storedOriginalIMP }, setOriginalIMP: { [weak self] in self?.storedOriginalIMP = $0 } ) + + // ============================================================================ // + // MARK: Validation + // ============================================================================ // - /// Subclass that we create on the fly - - func validate() throws { + internal func validate() throws { guard class_getInstanceMethod(self.class, self.selector) != nil else { throw InterposeError.methodNotFound( class: self.class, @@ -55,16 +70,22 @@ final class ObjectHookStrategy: HookStrategy { } } - func replaceImplementation() throws { + // ============================================================================ // + // MARK: Installing Implementation + // ============================================================================ // + + internal func replaceImplementation() throws { let hookIMP = self.makeHookIMP() - self.appliedHookIMP = hookIMP - ObjectHookRegistry.register(self.handle, for: hookIMP) + // Fetch the method, whose implementation we want to replace. guard let method = class_getInstanceMethod(self.class, self.selector) else { - throw InterposeError.methodNotFound(class: self.class, selector: self.selector) + throw InterposeError.methodNotFound( + class: self.class, + selector: self.selector + ) } - // The implementation of the call that is hooked must exist. + // Ensure that the method has an associated implementation. guard self.lookUpIMP() != nil else { throw InterposeError.implementationNotFound( class: self.class, @@ -72,42 +93,54 @@ final class ObjectHookStrategy: HookStrategy { ) } - // Check if there's an existing subclass we can reuse. - // Create one at runtime if there is none. - let dynamicSubclass: AnyClass = try ObjectSubclassManager.ensureSubclassInstalled(for: self.object) + // Retrieve a ready-to-use dynamic subclass. It might be reused if the object already + // has one installed or a newly created one. + let subclass: AnyClass = try ObjectSubclassManager.ensureSubclassInstalled(for: self.object) - // This function searches superclasses for implementations - let classImplementsMethod = class_implementsInstanceMethod(dynamicSubclass, self.selector) - let encoding = method_getTypeEncoding(method) - - // If the subclass is empty, we create a super trampoline first. - // If a hook already exists, we must skip this. - if !classImplementsMethod { + // If the dynamic subclass does not implement the method directly, we create a super + // trampoline first. Otherwise, when a hook for that method has already been applied + // (and potentially reverted), we skip this step. + if !class_implementsInstanceMethod(subclass, self.selector) { do { - try ITKSuperBuilder.addSuperInstanceMethod(to: dynamicSubclass, selector: self.selector) - let imp = class_getMethodImplementation(dynamicSubclass, self.selector)! - Interpose.log("Added super trampoline for -[\(dynamicSubclass) \(self.selector)] IMP: \(imp)") + try ITKSuperBuilder.addSuperInstanceMethod( + to: subclass, + selector: self.selector + ) + + Interpose.log({ + var message = "Added super trampoline for -[\(subclass) \(self.selector)]" + if let imp = class_getMethodImplementation(subclass, self.selector) { + message += " IMP: \(imp)" + } + return message + }()) } catch { - // Interpose.log("Failed to add super implementation to -[\(dynamicClass).\(selector)]: \(error)") - throw InterposeError.unknownError(String(describing: error)) + throw InterposeError.failedToAddSuperTrampoline( + class: subclass, + selector: self.selector, + underlyingError: error as NSError + ) } } - // Replace IMP (by now we guarantee that it exists) - self.storedOriginalIMP = class_replaceMethod(dynamicSubclass, self.selector, hookIMP, encoding) - guard self.storedOriginalIMP != nil else { + guard let imp = class_replaceMethod(subclass, self.selector, hookIMP, method_getTypeEncoding(method)) else { // This should not happen if the class implements the method or we have installed // the super trampoline. Instead, we should make the trampoline implementation // failable. throw InterposeError.implementationNotFound( - class: dynamicSubclass, + class: subclass, selector: self.selector ) } + + self.appliedHookIMP = hookIMP + self.storedOriginalIMP = imp + ObjectHookRegistry.register(self.handle, for: hookIMP) + Interpose.log("Replaced implementation for -[\(self.class) \(self.selector)] IMP: \(self.storedOriginalIMP!) -> \(hookIMP)") } - func restoreImplementation() throws { + internal func restoreImplementation() throws { guard let hookIMP = self.appliedHookIMP else { return } guard let originalIMP = self.storedOriginalIMP else { return } @@ -126,7 +159,11 @@ final class ObjectHookStrategy: HookStrategy { } guard let currentIMP = class_getMethodImplementation(dynamicSubclass, self.selector) else { - throw InterposeError.unknownError("No Implementation found") + // Do we need this??? + throw InterposeError.implementationNotFound( + class: self.class, + selector: self.selector + ) } // We are the topmost hook, replace method. @@ -156,6 +193,10 @@ final class ObjectHookStrategy: HookStrategy { // self.dynamicSubclass = nil } + // ============================================================================ // + // MARK: Helpers + // ============================================================================ // + /// Traverses the object hook chain to find the handle to the parent of this hook, starting /// from the topmost IMP for the hooked method. /// diff --git a/Sources/InterposeKit/InterposeError.swift b/Sources/InterposeKit/InterposeError.swift index 8027dda..1725080 100644 --- a/Sources/InterposeKit/InterposeError.swift +++ b/Sources/InterposeKit/InterposeError.swift @@ -1,4 +1,4 @@ -import ObjectiveC +import Foundation public enum InterposeError: Error { @@ -79,9 +79,21 @@ public enum InterposeError: Error { object: NSObject, actualClass: AnyClass ) + + /// Failed to add a super trampoline for the specified class and selector. + /// + /// When interposing an instance method on a dynamic subclass, InterposeKit installs + /// a *super trampoline*—a method that forwards calls to the original implementation + /// in the superclass. This allows the hook to delegate to the original behavior when needed. + /// + /// This error is thrown when the trampoline cannot be added, which is very rare. + /// Refer to the underlying error for more details. + case failedToAddSuperTrampoline( + class: AnyClass, + selector: Selector, + underlyingError: NSError + ) - /// Generic failure - case unknownError(_ reason: String) } extension InterposeError: Equatable { @@ -143,10 +155,13 @@ extension InterposeError: Equatable { return false } - case let .unknownError(lhsReason): + case let .failedToAddSuperTrampoline(lhsClass, lhsSelector, lhsError): switch rhs { - case let .unknownError(rhsReason): - return lhsReason == rhsReason + case let .failedToAddSuperTrampoline(rhsClass, rhsSelector, rhsError): + return lhsClass == rhsClass + && lhsSelector == rhsSelector + && lhsError.domain == rhsError.domain + && lhsError.code == rhsError.code default: return false } From 105c8a856eeca1fd637d91dfd76e16716f784a46 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 3 Apr 2025 14:52:05 +0200 Subject: [PATCH 15/18] Refactoring in ObjectHookStrategy 2/ --- .../ClassHookStrategy/ClassHookStrategy.swift | 6 ++ .../ObjectHookStrategy.swift | 65 +++++++++++-------- 2 files changed, 43 insertions(+), 28 deletions(-) diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift index e30b71a..8acb4a8 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift @@ -38,6 +38,7 @@ internal final class ClassHookStrategy: HookStrategy { // ============================================================================ // internal func validate() throws { + // Ensure that the method exists. guard class_getInstanceMethod(self.class, self.selector) != nil else { throw InterposeError.methodNotFound( class: self.class, @@ -45,6 +46,7 @@ internal final class ClassHookStrategy: HookStrategy { ) } + // Ensure that the class directly implements the method. guard class_implementsInstanceMethod(self.class, self.selector) else { throw InterposeError.methodNotDirectlyImplemented( class: self.class, @@ -61,6 +63,8 @@ internal final class ClassHookStrategy: HookStrategy { let hookIMP = self.makeHookIMP() guard let method = class_getInstanceMethod(self.class, self.selector) else { + // This should not happen under normal circumstances, as we perform validation upon + // creating the hook strategy, which itself checks for the presence of the method. throw InterposeError.methodNotFound( class: self.class, selector: self.selector @@ -73,6 +77,8 @@ internal final class ClassHookStrategy: HookStrategy { hookIMP, method_getTypeEncoding(method) ) else { + // This should not happen under normal circumstances, as we perform validation upon + // creating the hook strategy, which checks if the class directly implements the method. throw InterposeError.implementationNotFound( class: self.class, selector: self.selector diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift index d0407a9..1c24e9b 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift @@ -46,6 +46,7 @@ internal final class ObjectHookStrategy: HookStrategy { // ============================================================================ // internal func validate() throws { + // Ensure that the method exists. guard class_getInstanceMethod(self.class, self.selector) != nil else { throw InterposeError.methodNotFound( class: self.class, @@ -53,6 +54,16 @@ internal final class ObjectHookStrategy: HookStrategy { ) } + // Ensure that the method has an associated implementation (can be in a superclass). + guard self.lookUpIMP() != nil else { + throw InterposeError.implementationNotFound( + class: self.class, + selector: self.selector + ) + } + + // Ensure that the object either does not have a dynamic subclass installed or that + // it is the subclass installed by InterposeKit rather than by KVO or other mechanism. let perceivedClass: AnyClass = type(of: self.object) let actualClass: AnyClass = object_getClass(self.object) @@ -85,14 +96,6 @@ internal final class ObjectHookStrategy: HookStrategy { ) } - // Ensure that the method has an associated implementation. - guard self.lookUpIMP() != nil else { - throw InterposeError.implementationNotFound( - class: self.class, - selector: self.selector - ) - } - // Retrieve a ready-to-use dynamic subclass. It might be reused if the object already // has one installed or a newly created one. let subclass: AnyClass = try ObjectSubclassManager.ensureSubclassInstalled(for: self.object) @@ -123,10 +126,15 @@ internal final class ObjectHookStrategy: HookStrategy { } } - guard let imp = class_replaceMethod(subclass, self.selector, hookIMP, method_getTypeEncoding(method)) else { - // This should not happen if the class implements the method or we have installed - // the super trampoline. Instead, we should make the trampoline implementation - // failable. + guard let originalIMP = class_replaceMethod( + subclass, + self.selector, + hookIMP, + method_getTypeEncoding(method) + ) else { + // This should not fail under normal circumstances, as the subclass should already + // have an associated implementation, which might be the just-installed trampoline + // or an existing hook. throw InterposeError.implementationNotFound( class: subclass, selector: self.selector @@ -134,10 +142,10 @@ internal final class ObjectHookStrategy: HookStrategy { } self.appliedHookIMP = hookIMP - self.storedOriginalIMP = imp + self.storedOriginalIMP = originalIMP ObjectHookRegistry.register(self.handle, for: hookIMP) - Interpose.log("Replaced implementation for -[\(self.class) \(self.selector)] IMP: \(self.storedOriginalIMP!) -> \(hookIMP)") + Interpose.log("Replaced implementation for -[\(self.class) \(self.selector)] IMP: \(originalIMP) -> \(hookIMP)") } internal func restoreImplementation() throws { @@ -155,20 +163,28 @@ internal final class ObjectHookStrategy: HookStrategy { ) else { return } guard let method = class_getInstanceMethod(self.class, self.selector) else { - throw InterposeError.methodNotFound(class: self.class, selector: self.selector) + throw InterposeError.methodNotFound( + class: self.class, + selector: self.selector + ) } guard let currentIMP = class_getMethodImplementation(dynamicSubclass, self.selector) else { - // Do we need this??? throw InterposeError.implementationNotFound( class: self.class, selector: self.selector ) } - // We are the topmost hook, replace method. + // If we are the topmost hook, we have to replace the implementation on the subclass. if currentIMP == hookIMP { - let previousIMP = class_replaceMethod(dynamicSubclass, self.selector, originalIMP, method_getTypeEncoding(method)) + let previousIMP = class_replaceMethod( + dynamicSubclass, + self.selector, + originalIMP, + method_getTypeEncoding(method) + ) + guard previousIMP == hookIMP else { throw InterposeError.revertCorrupted( class: dynamicSubclass, @@ -176,21 +192,14 @@ internal final class ObjectHookStrategy: HookStrategy { imp: previousIMP ) } - Interpose.log("Restored implementation for -[\(self.class) \(self.selector)] IMP: \(originalIMP)") } else { + // Otherwise, find the next hook and set its original IMP to this hook’s original IMP, + // effectively unlinking this hook from the chain. let nextHook = self._findParentHook(from: currentIMP) - // Replace next's original IMP nextHook?.originalIMP = originalIMP } - - - // FUTURE: remove class pair! - // This might fail if we get KVO observed. - // objc_disposeClassPair does not return a bool but logs if it fails. - // - // objc_disposeClassPair(dynamicSubclass) - // self.dynamicSubclass = nil + Interpose.log("Restored implementation for -[\(self.class) \(self.selector)] IMP: \(originalIMP)") } // ============================================================================ // From 2b4ddc0b737463edfa33bb16fa388d20d73b483a Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 3 Apr 2025 15:56:25 +0200 Subject: [PATCH 16/18] Added support for cleanup of the dynamic subclass --- .../ObjectHookRegistry.swift | 8 +-- .../ObjectHookStrategy.swift | 58 ++++++++++++++++++ .../ObjectSubclassManager.swift | 24 +++++++- Tests/InterposeKitTests/ObjectHookTests.swift | 60 +++++++++++++++++-- 4 files changed, 141 insertions(+), 9 deletions(-) diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookRegistry.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookRegistry.swift index e32176c..15385a1 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookRegistry.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookRegistry.swift @@ -13,7 +13,7 @@ internal enum ObjectHookRegistry { objc_setAssociatedObject( block, - &self.associatedKey, + &ObjectHookRegistryKey, WeakReference(handle), .OBJC_ASSOCIATION_RETAIN ) @@ -26,16 +26,16 @@ internal enum ObjectHookRegistry { guard let reference = objc_getAssociatedObject( block, - &self.associatedKey + &ObjectHookRegistryKey ) as? WeakReference else { return nil } return reference.object } - private static var associatedKey: UInt8 = 0 - } +fileprivate var ObjectHookRegistryKey: UInt8 = 0 + fileprivate class WeakReference: NSObject { fileprivate init(_ object: T?) { diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift index 1c24e9b..a79cdb9 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift @@ -143,6 +143,8 @@ internal final class ObjectHookStrategy: HookStrategy { self.appliedHookIMP = hookIMP self.storedOriginalIMP = originalIMP + + self.object.incrementHookCount() ObjectHookRegistry.register(self.handle, for: hookIMP) Interpose.log("Replaced implementation for -[\(self.class) \(self.selector)] IMP: \(originalIMP) -> \(hookIMP)") @@ -200,6 +202,11 @@ internal final class ObjectHookStrategy: HookStrategy { } Interpose.log("Restored implementation for -[\(self.class) \(self.selector)] IMP: \(originalIMP)") + + // Decrement the hook count and if this was the last hook, uninstall the dynamic subclass. + if self.object.decrementHookCount() { + ObjectSubclassManager.uninstallSubclass(for: object) + } } // ============================================================================ // @@ -233,3 +240,54 @@ internal final class ObjectHookStrategy: HookStrategy { } } + +extension NSObject { + + /// Increments the number of active object-based hooks on this instance by one. + fileprivate func incrementHookCount() { + self.hookCount += 1 + } + + /// Decrements the number of active object-based hooks on this instance by one and returns + /// `true` if this was the last hook, or `false` otherwise. + fileprivate func decrementHookCount() -> Bool { + guard self.hookCount > 0 else { return false } + self.hookCount -= 1 + return self.hookCount == 0 + } + + /// The current number of active object-based hooks on this instance. + /// + /// Internally stored using associated objects. Always returns a non-negative value. + private var hookCount: Int { + get { + guard let count = objc_getAssociatedObject( + self, + &ObjectHookCountKey + ) as? NSNumber else { return 0 } + + return count.intValue + } + set { + let newCount = max(0, newValue) + if newCount == 0 { + objc_setAssociatedObject( + self, + &ObjectHookCountKey, + nil, + .OBJC_ASSOCIATION_ASSIGN + ) + } else { + objc_setAssociatedObject( + self, + &ObjectHookCountKey, + NSNumber(value: newCount), + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + } + +} + +private var ObjectHookCountKey: UInt8 = 0 diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectSubclassManager.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectSubclassManager.swift index fe24ba3..52a4ed2 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectSubclassManager.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectSubclassManager.swift @@ -65,7 +65,29 @@ internal enum ObjectSubclassManager { internal static func uninstallSubclass( for object: NSObject ) { - fatalError("Not yet implemented") + // Get the InterposeKit-managed dynamic subclass installed on the object. + guard let dynamicSubclass = self.installedSubclass(for: object) else { return } + + // Retrieve the original class (superclass of the dynamic subclass) we want to restore + // the object to. + guard let originalClass = class_getSuperclass(dynamicSubclass) else { return } + + // Restore the object’s class to its original class. + object_setClass(object, originalClass) + + Interpose.log({ + let subclassName = NSStringFromClass(dynamicSubclass) + let originalClassName = NSStringFromClass(originalClass) + let objectAddress = String(format: "%p", object) + return "Removed subclass: \(subclassName), restored \(originalClassName) on object \(objectAddress)" + }()) + + // Dispose of the dynamic subclass. + // + // This is safe to call here because all hooks have been reverted. Unfortunately, we can’t + // validate this explicitly, as `objc_disposeClassPair(...)` offers no feedback mechanism + // and will silently fail if the subclass is still in use. + objc_disposeClassPair(dynamicSubclass) } // ============================================================================ // diff --git a/Tests/InterposeKitTests/ObjectHookTests.swift b/Tests/InterposeKitTests/ObjectHookTests.swift index 85bd6bb..03385ab 100644 --- a/Tests/InterposeKitTests/ObjectHookTests.swift +++ b/Tests/InterposeKitTests/ObjectHookTests.swift @@ -4,6 +4,7 @@ import XCTest fileprivate class ExampleClass: NSObject { @objc dynamic var intValue = 1 @objc dynamic func doSomething() {} + @objc dynamic func doSomethingElse() {} @objc dynamic var arrayValue: [String] { ["base"] } } @@ -48,7 +49,7 @@ final class ObjectHookTests: XCTestCase { ) } - func testMultipleHooks() throws { + func testMultipleHooksOnSingleObject() throws { let object = ExampleClass() XCTAssertEqual(object.arrayValue, ["base"]) @@ -82,7 +83,7 @@ final class ObjectHookTests: XCTestCase { XCTAssertEqual(object.arrayValue, ["base"]) } - func testHookOnMultipleObjects() throws { + func testHooksOnMultipleObjects() throws { let object1 = ExampleClass() let object2 = ExampleClass() @@ -201,7 +202,7 @@ final class ObjectHookTests: XCTestCase { _ = token } - func testCleanUp_implementationPreserved() throws { + func testCleanUp_hook_implementationPreserved() throws { let object = ExampleClass() var deallocated = false @@ -223,7 +224,7 @@ final class ObjectHookTests: XCTestCase { XCTAssertFalse(deallocated) } - func testCleanUp_implementationDeallocated() throws { + func testCleanUp_hook_implementationDeallocated() throws { let object = ExampleClass() var deallocated = false @@ -246,5 +247,56 @@ final class ObjectHookTests: XCTestCase { XCTAssertTrue(deallocated) } + + func testCleanUp_dynamicSubclass() throws { + let object = ExampleClass() + + // Original class + XCTAssertTrue(object_getClass(object) == ExampleClass.self) + + let hook1 = try object.applyHook( + for: #selector(ExampleClass.doSomething), + methodSignature: (@convention(c) (NSObject, Selector) -> Void).self, + hookSignature: (@convention(block) (NSObject) -> Void).self + ) { hook in + return { `self` in hook.original(self, hook.selector) } + } + + let hook2 = try object.applyHook( + for: #selector(ExampleClass.doSomething), + methodSignature: (@convention(c) (NSObject, Selector) -> Void).self, + hookSignature: (@convention(block) (NSObject) -> Void).self + ) { hook in + return { `self` in hook.original(self, hook.selector) } + } + + let hook3 = try object.applyHook( + for: #selector(ExampleClass.doSomethingElse), + methodSignature: (@convention(c) (NSObject, Selector) -> Void).self, + hookSignature: (@convention(block) (NSObject) -> Void).self + ) { hook in + return { `self` in hook.original(self, hook.selector) } + } + + // Dynamic subclass + XCTAssertTrue(object_getClass(object) != ExampleClass.self) + + try hook1.revert() + try hook2.revert() + + // Dynamic subclass + XCTAssertTrue(object_getClass(object) != ExampleClass.self) + + // Back to original subclass after reverting the last hook + try hook3.revert() + XCTAssertTrue(object_getClass(object) == ExampleClass.self) + + try hook2.apply() + XCTAssertTrue(object_getClass(object) != ExampleClass.self) + + // Back to original subclass after reverting the last hook + try hook2.revert() + XCTAssertTrue(object_getClass(object) == ExampleClass.self) + } } From e0769308ffbec17a30e242269226a8e96921d528 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 3 Apr 2025 16:32:28 +0200 Subject: [PATCH 17/18] Attempting to apply/revert a failed hook now throws error --- Sources/InterposeKit/Hooks/Hook.swift | 42 ++++++++++++-------- Sources/InterposeKit/InterposeError.swift | 11 +++++ Tests/InterposeKitTests/ClassHookTests.swift | 5 +++ 3 files changed, 42 insertions(+), 16 deletions(-) diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index 5fb43e7..bce4c9f 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -120,27 +120,37 @@ public final class Hook { /// Applies the hook by interposing the method implementation. public func apply() throws { - guard self.state == .pending else { return } - - do { - try self.strategy.replaceImplementation() - self.state = .active - } catch { - self.state = .failed - throw error + switch self.state { + case .pending: + do { + try self.strategy.replaceImplementation() + self.state = .active + } catch { + self.state = .failed + throw error + } + case .failed: + throw InterposeError.hookInFailedState + case .active: + return } } /// Reverts the hook, restoring the original method implementation. public func revert() throws { - guard self.state == .active else { return } - - do { - try self.strategy.restoreImplementation() - self.state = .pending - } catch { - self.state = .failed - throw error + switch self.state { + case .active: + do { + try self.strategy.restoreImplementation() + self.state = .pending + } catch { + self.state = .failed + throw error + } + case .failed: + throw InterposeError.hookInFailedState + case .pending: + return } } diff --git a/Sources/InterposeKit/InterposeError.swift b/Sources/InterposeKit/InterposeError.swift index 1725080..2b55410 100644 --- a/Sources/InterposeKit/InterposeError.swift +++ b/Sources/InterposeKit/InterposeError.swift @@ -2,6 +2,9 @@ import Foundation public enum InterposeError: Error { + /// A hook operation failed and the hook is no longer usable. + case hookInFailedState + /// No instance method found for the selector on the specified class. /// /// This typically occurs when mistyping a stringified selector or attempting to interpose @@ -165,6 +168,14 @@ extension InterposeError: Equatable { default: return false } + + case .hookInFailedState: + switch rhs { + case .hookInFailedState: + return true + default: + return false + } } } } diff --git a/Tests/InterposeKitTests/ClassHookTests.swift b/Tests/InterposeKitTests/ClassHookTests.swift index f5c94f8..f00d3a8 100644 --- a/Tests/InterposeKitTests/ClassHookTests.swift +++ b/Tests/InterposeKitTests/ClassHookTests.swift @@ -293,6 +293,11 @@ final class ClassHookTests: XCTestCase { hook.debugDescription, #"^Failed hook for -\[ExampleClass doSomething\]$"# ) + + XCTAssertThrowsError( + try hook.revert(), + expected: InterposeError.hookInFailedState + ) } func testCleanUp_implementationPreserved() throws { From 047329d9b897f73972bf3527180c22ab48e96409 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 3 Apr 2025 16:34:01 +0200 Subject: [PATCH 18/18] Added KVO detection to the revert operation --- .../ObjectHookStrategy/ObjectHookStrategy.swift | 4 ++++ Tests/InterposeKitTests/ObjectHookTests.swift | 10 +++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift index a79cdb9..81eef05 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift @@ -151,6 +151,10 @@ internal final class ObjectHookStrategy: HookStrategy { } internal func restoreImplementation() throws { + if object_isKVOActive(self.object) { + throw InterposeError.kvoDetected(object: self.object) + } + guard let hookIMP = self.appliedHookIMP else { return } guard let originalIMP = self.storedOriginalIMP else { return } diff --git a/Tests/InterposeKitTests/ObjectHookTests.swift b/Tests/InterposeKitTests/ObjectHookTests.swift index 03385ab..e9d4367 100644 --- a/Tests/InterposeKitTests/ObjectHookTests.swift +++ b/Tests/InterposeKitTests/ObjectHookTests.swift @@ -166,7 +166,10 @@ final class ObjectHookTests: XCTestCase { _ = token } - // KVO works just fine on an object that has already been hooked. + // KVO works on an object that was hooked earlier, but reverting the hook while an active + // observer is installed fails. While it is technically possible to recover by stopping + // the observation before reverting, the hook enters a failed state on error, preventing + // it from accepting further operations. func testKVO_observationAfterHooking() throws { let object = ExampleClass() @@ -199,6 +202,11 @@ final class ObjectHookTests: XCTestCase { XCTAssertEqual(object.intValue, 3) XCTAssertEqual(didInvokeObserver, true) + XCTAssertThrowsError( + try hook.revert(), + expected: InterposeError.kvoDetected(object: object) + ) + _ = token }