Function Hooking on macOS: Interposing, Swizzling, and a Stolen KeePass Password
Once your code is running inside a process (whether you got there by injection, a hijacked dylib,
or simply being a plugin) the next move is usually to intercept what the process does. Hooking is
the act of inserting yourself between a caller and a function: the program calls printf, but your
code runs first, sees the arguments, and decides what to pass along. Done well, the application
never notices.
macOS gives you two clean, supported ways to hook, and the second one is almost unfair. Function interposing is a dyld feature for replacing C functions. Method swizzling abuses the Objective-C runtime to swap method implementations at runtime, and because Objective-C resolves every call by name through mutable tables, swizzling is not a hack against the runtime so much as a use of it. This post covers both, then walks a payoff example: swizzling a method to lift a KeePass master password out of memory as the user types it.
Function Interposing: Replacing C Functions Through dyld
Interposing is built into the dynamic loader. You create a dylib with a special __interpose
section in its __DATA segment, containing pairs of function pointers: your replacement and the
original. When that dylib is loaded into a process (via DYLD_INSERT_LIBRARIES, a hijack, or a
plugin), dyld rewrites the bindings so every call to the original lands in your replacement
instead.
Apple ships the machinery as a macro, DYLD_INTERPOSE, in dyld’s
mach-o/dyld-interposing.h. The macro just emits the __interpose tuple for you:
// filename: interpose.c
#include <stdio.h>
#define DYLD_INTERPOSE(_replacement, _replacee) \
__attribute__((used)) static struct { \
const void *replacement; const void *replacee; \
} _interpose_##_replacee __attribute__((section("__DATA, __interpose"))) = { \
(const void *)(unsigned long)&_replacement, \
(const void *)(unsigned long)&_replacee };
int my_printf(const char *format, ...) {
return printf("[+] No more hello world\n");
}
DYLD_INTERPOSE(my_printf, printf);
Compile it as a dylib and confirm the section landed where it should:
# filename: build-interpose.txt
% gcc -dynamiclib interpose.c -o interpose.dylib
% size -x -m -l interpose.dylib
Section __interpose: 0x10 (addr 0x8008 offset 32776)
% DYLD_INSERT_LIBRARIES=interpose.dylib ./hello
[+] No more hello world
The hello program still calls printf; dyld quietly redirected that call into my_printf.
Notice that interposing happens before main runs, which is why the replacement dylib has to be
present at load time: you cannot interpose a function the program already started calling.
Two things make interposing more than a toy. First, your replacement can call the original (it
sees the real arguments, can log or modify them, then forward the call) so it is an interception
point, not just a substitution. A second example interposes ioctl to inspect the
device-control requests an application issues, which is genuinely useful for driver analysis and
fuzzing. That example also exposes interposing’s sharp edge: ioctl takes variadic arguments
(int ioctl(int, unsigned long,...)), and forwarding a variadic call correctly through a hook
takes care: get it wrong and you corrupt the arguments.
Second, the limitation that matters for offense: interposing relies on DYLD_INSERT_LIBRARIES-style
loading, so it inherits all the restrictions from the dylib-injection post. It will not work against
a restricted process: setuid, __RESTRICT segment, or hardened-runtime entitlements. Like
every loader-based technique, it works exactly where the loader is willing to load your code.
The Objective-C Runtime: Why Swizzling Works
Interposing handles C. The more interesting target on macOS is Objective-C, and to hook it you have
to understand how a method call actually executes. When you write [obj doThing:arg], the compiler
does not emit a direct call. It emits a call to objc_msgSend:
// filename: msgsend.m
NSString *str = @"My string";
// [str isEqual:str2] compiles to:
int i = ((int (*)(id, SEL, NSString *))objc_msgSend)(str, sel_registerName("isEqual:"), str2);
objc_msgSend takes the receiver (id), a selector (SEL: the method’s name as a runtime
token), and the arguments. At runtime it looks up the selector in the receiver’s class, finds the
matching method, and the method holds an IMP: a plain function pointer to the
implementation:
// filename: imp.m
typedef id (*IMP)(id, SEL, ...); // pointer to a method's implementation
The runtime exposes all of this as a public API. You can ask a class for a method object with
class_getInstanceMethod(cls, sel), pull its IMP with method_getImplementation, turn class and
selector names into strings with NSStringFromClass / NSStringFromSelector, and, the part that
matters, change the implementation behind a selector. The dispatch tables are mutable at runtime
by design.
That single fact is why swizzling is possible. A method call is a name lookup in a table you are allowed to edit. Rewrite the table entry and every future call to that selector runs your code, across every instance of the class, with no patching of call sites and no fight with code signing: you are not modifying the binary, you are using the runtime exactly as documented.
Method Swizzling: Swapping Implementations
The clean technique uses an Objective-C category to add your replacement method to the target class, then swaps the two implementations with one call:
// filename: swizzle.m
#import <objc/runtime.h>
// In a category on the target class:
Method original = class_getInstanceMethod(targetClass, @selector(originalMethod:));
Method swizzled = class_getInstanceMethod(targetClass, @selector(mySwizzledMethod:));
method_exchangeImplementations(original, swizzled);
method_exchangeImplementations swaps the IMP pointers of the two methods atomically. After the
swap, calls to originalMethod: run your mySwizzledMethod:, and, the useful trick, calls to
your selector name now run the original implementation. So inside your replacement you can invoke
“yourself” to call through to the genuine method:
// filename: swizzle-body.m
- (void)mySwizzledMethod:(NSString *)arg {
NSLog(@"[+] intercepted: %@", arg); // observe / tamper
[self mySwizzledMethod:arg]; // NOT recursion — this hits the ORIGINAL now
}
That [self mySwizzledMethod:arg] looks like infinite recursion and is not, because the
implementations were exchanged: the selector you are calling now points at the original code. It is
the idiom that trips up everyone the first time and the reason swizzled hooks can be transparent:
intercept, log, forward, and the application’s behavior is unchanged except for whatever you chose
to do in between.
Swizzling is global to the class. Exchange the implementation and every instance, including ones the framework creates internally, runs your code. That breadth is what makes it powerful for spying on an app’s behavior and what makes it easy to destabilize if your replacement is not faithful to the original’s contract.
Sniffing a KeePass Master Password
This post turns swizzling on KeePass, the password manager, and the attack is a tidy demonstration of why hooking inside a process is so potent. KeePass’s whole security model assumes the master password lives only in the user’s head and briefly in the app’s memory. But you are inside the app, injected as a dylib, and the password necessarily passes through an Objective-C method on its way from the text field to the decryption routine.
The plan:
- Get loaded into KeePass. A dylib in the process, via injection or a hijack: whichever the app’s entitlements permit. (This is where the earlier posts pay off; the entitlement audit decides which loading technique works.)
- Identify the method that handles the password. The field where the user types the master
password is backed by an Objective-C control, and reading its value flows through a known
selector: the kind of
setStringValue:/stringValueaccessor every Cocoa text field has. Hopper and the runtime introspection APIs locate it. - Swizzle that selector. Exchange the real implementation for yours.
- Capture and forward. Your replacement reads the
NSStringargument, the master password in cleartext, logs or exfiltrates it, then calls through to the original so KeePass behaves completely normally. The user unlocks their database; you have their master key.
No cryptography was broken. The vault’s encryption is irrelevant when you read the password before it is ever used as a key. That is the general lesson of in-process hooking: encryption, hashing, and careful key handling all protect data at rest and in transit, and none of it protects the moment the plaintext sits in a method argument inside a process you control.
Why This Matters
Interposing and swizzling are the two halves of macOS hooking: one for C, one for Objective-C. Both are documented, both are supported, and that is exactly what makes them dangerous: they are not exploits against the runtime, they are the runtime working as designed. The defensive implication is uncomfortable: once arbitrary code is running in your process, the boundaries you trusted inside it evaporate. A method call is a table lookup an attacker can edit; a C function is a binding dyld can rewrite.
This is also why the real security boundaries on macOS are between processes, not inside them: the sandbox, TCC, XPC service separation, the kernel. If an attacker is already in your address space, hooking gives them everything; the game was lost at the injection. The rest of this series turns to those inter-process boundaries, starting with the richest and most bug-prone of them: XPC, where privileged helper services take requests from unprivileged clients and too often forget to ask who is calling.
References
- Apple. dyld interposing: mach-o/dyld-interposing.h. opensource.apple.com.
- Thompson, M. Method Swizzling. NSHipster, 2014.
- Apple. Objective-C Runtime Reference: objc_msgSend, class_getInstanceMethod, method_exchangeImplementations. developer.apple.com.